From d15ce2e4363897496b7db5b532d08adb9132bc9e Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 5 Mar 2026 11:46:50 -0500 Subject: [PATCH 01/64] 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/64] 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/64] 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/64] 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 d6b98f6770751e2ceac5354b46ad717f045842de Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 2 Apr 2026 02:03:04 -0400 Subject: [PATCH 05/64] 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 --- src/cli/aws/agentcore-config-bundles.ts | 364 ++++++++++++++++++ src/cli/cloudformation/outputs.ts | 6 + src/cli/commands/deploy/actions.ts | 33 ++ .../commands/logs/__tests__/action.test.ts | 4 + src/cli/commands/remove/command.tsx | 1 + src/cli/commands/remove/types.ts | 3 +- src/cli/commands/status/action.ts | 12 +- .../__tests__/checks-extended.test.ts | 10 + src/cli/logging/remove-logger.ts | 3 +- .../agent/generate/write-agent-to-project.ts | 1 + src/cli/operations/deploy/index.ts | 8 + .../deploy/post-deploy-config-bundles.ts | 192 +++++++++ .../operations/dev/__tests__/config.test.ts | 20 + src/cli/primitives/ConfigBundlePrimitive.ts | 232 +++++++++++ .../__tests__/GatewayPrimitive.test.ts | 1 + .../primitives/__tests__/auth-utils.test.ts | 1 + src/cli/primitives/registry.ts | 3 + src/cli/project.ts | 1 + src/cli/tui/components/ResourceGraph.tsx | 26 +- src/cli/tui/hooks/useCreateConfigBundle.ts | 59 +++ src/cli/tui/hooks/useRemove.ts | 19 + src/cli/tui/screens/add/AddFlow.tsx | 18 + src/cli/tui/screens/add/AddScreen.tsx | 1 + .../config-bundle/AddConfigBundleFlow.tsx | 88 +++++ .../config-bundle/AddConfigBundleScreen.tsx | 197 ++++++++++ src/cli/tui/screens/config-bundle/index.ts | 1 + src/cli/tui/screens/config-bundle/types.ts | 42 ++ .../config-bundle/useAddConfigBundleWizard.ts | 116 ++++++ .../remove/RemoveConfigBundleScreen.tsx | 26 ++ src/cli/tui/screens/remove/RemoveFlow.tsx | 112 +++++- src/cli/tui/screens/remove/RemoveScreen.tsx | 11 + .../remove/__tests__/RemoveScreen.test.tsx | 2 + src/schema/schemas/agentcore-project.ts | 14 + src/schema/schemas/deployed-state.ts | 13 + .../schemas/primitives/config-bundle.ts | 48 +++ 35 files changed, 1682 insertions(+), 6 deletions(-) create mode 100644 src/cli/aws/agentcore-config-bundles.ts create mode 100644 src/cli/operations/deploy/post-deploy-config-bundles.ts create mode 100644 src/cli/primitives/ConfigBundlePrimitive.ts create mode 100644 src/cli/tui/hooks/useCreateConfigBundle.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/remove/RemoveConfigBundleScreen.tsx create mode 100644 src/schema/schemas/primitives/config-bundle.ts diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts new file mode 100644 index 000000000..17d27c8ed --- /dev/null +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -0,0 +1,364 @@ +/** + * 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 { 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 ListConfigurationBundleVersionsOptions { + region: string; + bundleId: string; + maxResults?: number; + nextToken?: string; +} + +export interface ConfigurationBundleVersionSummary { + bundleArn: string; + bundleId: string; + versionId: string; + lineageMetadata?: VersionLineageMetadata; + versionCreatedAt: string; +} + +export interface ListConfigurationBundleVersionsResult { + versions: ConfigurationBundleVersionSummary[]; + nextToken?: string; +} + +// ============================================================================ +// 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}.amazonaws.com`; +} + +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 stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + const service = stage === 'beta' || stage === 'gamma' ? 'bedrock-agentcore' : 'bedrock-agentcore-control'; + 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 = {}; + 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<{ success: boolean; error?: string }> { + try { + await signedRequest({ + region: options.region, + method: 'DELETE', + path: `/configuration-bundles/${options.bundleId}`, + }); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +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 data = await signedRequest({ + region: options.region, + method: 'POST', + path: `/configuration-bundles/${options.bundleId}/versions${query ? `?${query}` : ''}`, + }); + + const result = data as ListConfigurationBundleVersionsResult; + return { + versions: result.versions ?? [], + nextToken: result.nextToken, + }; +} diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 8151fcac8..48aca6cb1 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -404,6 +404,12 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.onlineEvalConfigs = onlineEvalConfigs; } + // 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; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 2ae8cf0e9..2486139cf 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -31,6 +31,7 @@ import { validateProject, } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; +import { setupConfigBundles } from '../../operations/deploy/post-deploy-config-bundles'; import type { DeployResult } from './types'; export interface ValidatedDeployOptions { @@ -443,6 +444,38 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { + try { + const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; + const configBundleResult = await setupConfigBundles({ + region: target.region, + projectSpec: context.projectSpec, + 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'); + } + } + } catch (err: unknown) { + logger.log(`Config bundle setup failed: ${getErrorMessage(err)}`, 'warn'); + } + } + // Post-deploy: Enable CloudWatch Transaction Search (non-blocking, silent) const nextSteps = agentNames.length > 0 ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS]; const notes: string[] = []; diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 039acfb67..efe717b0b 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -60,6 +60,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }, deployedState: { targets: { @@ -121,6 +122,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }, }); const result = resolveAgentContext(context, {}); @@ -162,6 +164,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }, deployedState: { targets: { @@ -213,6 +216,7 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index deb1a9274..a9b5acfec 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -34,6 +34,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise `${item.engineName}/${item.name}`, }); + const configBundles = diffResourceSet({ + resourceType: 'config-bundle', + localItems: project.configBundles ?? [], + deployedRecord: resources?.configBundles ?? {}, + getIdentifier: deployed => deployed.bundleArn, + getLocalDetail: item => item.description, + }); + return [ ...agents, ...credentials, @@ -209,6 +218,7 @@ export function computeResourceStatuses( ...onlineEvalConfigs, ...policyEngines, ...policies, + ...configBundles, ]; } diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts index 535d1b929..2ccd000d5 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -53,6 +53,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(requiresUv(project)).toBe(true); }); @@ -78,6 +79,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(requiresUv(project)).toBe(false); }); @@ -94,6 +96,7 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(requiresUv(project)).toBe(false); }); @@ -121,6 +124,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -146,6 +150,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -162,6 +167,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -195,6 +201,7 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -262,6 +269,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const result = await checkDependencyVersions(project); @@ -282,6 +290,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const result = await checkDependencyVersions(project); @@ -310,6 +319,7 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 2bfffcaa4..87a21a4e5 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -16,7 +16,8 @@ export interface RemoveLoggerOptions { | 'evaluator' | 'online-eval' | 'policy-engine' - | 'policy'; + | 'policy' + | 'config-bundle'; /** Name of the resource being removed */ resourceName: string; } 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..279d9189a 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,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index a5b9a2f9d..a420e7a05 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -45,6 +45,14 @@ export { // Post-deploy observability setup export { setupTransactionSearch, type TransactionSearchSetupResult } from './post-deploy-observability'; +// 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-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts new file mode 100644 index 000000000..446398815 --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -0,0 +1,192 @@ +import type { AgentCoreProjectSpec, ConfigBundleDeployedState } from '../../../schema'; +import { + createConfigurationBundle, + deleteConfigurationBundle, + 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)); + + // Create or update bundles from the spec + for (const bundleSpec of projectSpec.configBundles) { + try { + const existingBundle = existingBundles?.[bundleSpec.name]; + + if (existingBundle) { + // Update existing bundle (creates a new version) + const result = await updateConfigurationBundle({ + region, + bundleId: existingBundle.bundleId, + description: bundleSpec.description, + components: bundleSpec.components as ComponentConfigurationMap, + branchName: bundleSpec.branchName, + commitMessage: bundleSpec.commitMessage, + }); + + 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 { + // Try to find by name via list (handles re-creation after state loss) + const existingByName = await findBundleByName(region, bundleSpec.name); + + if (existingByName) { + // Update the existing one and track it + const result = await updateConfigurationBundle({ + region, + bundleId: existingByName.bundleId, + description: bundleSpec.description, + components: bundleSpec.components as ComponentConfigurationMap, + branchName: bundleSpec.branchName, + commitMessage: bundleSpec.commitMessage, + }); + + 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 + const result = await createConfigurationBundle({ + region, + bundleName: bundleSpec.name, + description: bundleSpec.description, + components: bundleSpec.components as ComponentConfigurationMap, + branchName: bundleSpec.branchName, + commitMessage: bundleSpec.commitMessage, + }); + + 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 { + const deleteResult = await deleteConfigurationBundle({ + region, + bundleId: bundleState.bundleId, + }); + + results.push({ + bundleName, + status: deleteResult.success ? 'deleted' : 'error', + error: deleteResult.error, + }); + } 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; + } +} diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 56b97ef64..8655c67ac 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -21,6 +21,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project); @@ -48,6 +49,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project); @@ -75,6 +77,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -108,6 +111,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -136,6 +140,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -162,6 +167,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -191,6 +197,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; // No configRoot provided @@ -220,6 +227,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -249,6 +257,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -277,6 +286,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -305,6 +315,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -333,6 +344,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -361,6 +373,7 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -403,6 +416,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -421,6 +435,7 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -444,6 +459,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -470,6 +486,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -504,6 +521,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const supported = getDevSupportedAgents(project); @@ -532,6 +550,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const supported = getDevSupportedAgents(project); @@ -568,6 +587,7 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts new file mode 100644 index 000000000..9a631429f --- /dev/null +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -0,0 +1,232 @@ +import { findConfigRoot } from '../../lib'; +import type { ConfigBundle } from '../../schema'; +import { ConfigBundleSchema } from '../../schema'; +import { getErrorMessage } from '../errors'; +import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { BasePrimitive } from './BasePrimitive'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; +import { readFileSync } from 'fs'; + +export interface AddConfigBundleOptions { + name: string; + description?: string; + components: Record }>; + 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('Add a configuration bundle to the project') + .option('--name ', 'Bundle name') + .option('--description ', 'Bundle description') + .option('--components ', 'Components map as inline JSON') + .option('--components-file ', 'Path to components JSON file') + .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, + 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 = { + type: 'ConfigurationBundle', + name: options.name, + ...(options.description && { description: options.description }), + components: options.components, + ...(options.branchName && { branchName: options.branchName }), + ...(options.commitMessage && { commitMessage: options.commitMessage }), + }; + + project.configBundles.push(bundle); + await this.writeProjectSpec(project); + + return bundle; + } +} diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index 4c4c66402..b4b04b230 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -13,6 +13,7 @@ const defaultProject: AgentCoreProjectSpec = { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; 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..7638f62ea 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -93,6 +93,7 @@ describe('createManagedOAuthCredential', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index fd46a6be7..0967de86c 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -1,5 +1,6 @@ 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'; @@ -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 configBundlePrimitive = new ConfigBundlePrimitive(); /** * All primitives in display order. @@ -36,6 +38,7 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ gatewayTargetPrimitive, policyEnginePrimitive, policyPrimitive, + configBundlePrimitive, ]; /** diff --git a/src/cli/project.ts b/src/cli/project.ts index d588b8ca6..bbc5d3b17 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -18,6 +18,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index e9da36dd3..0f14c81bf 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: '▢', + 'config-bundle': '⬡', } as const; interface ResourceGraphProps { @@ -121,6 +122,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const mcpRuntimeTools = mcp?.mcpRuntimeTools ?? []; const unassignedTargets = mcp?.unassignedTargets ?? []; const policyEngines = project.policyEngines ?? []; + const configBundles = project.configBundles ?? []; // Build lookup map and collect pending-removal resources in a single pass const { statusMap, pendingRemovals } = useMemo(() => { @@ -280,6 +282,27 @@ 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 ( + + ); + })} + + )} + {/* Removed locally — still deployed in AWS, will be torn down on next deploy */} {pendingRemovals.length > 0 && ( @@ -410,7 +433,8 @@ 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 {resourceStatuses && resourceStatuses.length > 0 && ( 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/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index ad97362ac..56e1391b9 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -6,6 +6,7 @@ import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; import { agentPrimitive, + configBundlePrimitive, credentialPrimitive, evaluatorPrimitive, gatewayPrimitive, @@ -147,6 +148,11 @@ export function useRemovablePolicies() { return { policies, ...rest }; } +export function useRemovableConfigBundles() { + const { items: configBundles, ...rest } = useRemovableResources(() => configBundlePrimitive.getRemovable()); + return { configBundles, ...rest }; +} + // ============================================================================ // Preview Hook // ============================================================================ @@ -218,6 +224,10 @@ 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 reset = useCallback(() => { setState({ isLoading: false, preview: null, error: null }); @@ -234,6 +244,7 @@ export function useRemovalPreview() { loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, + loadConfigBundlePreview, reset, }; } @@ -320,3 +331,11 @@ export function useRemovePolicy() { k => k ); } + +export function useRemoveConfigBundle() { + return useRemoveResource( + (name: string) => configBundlePrimitive.remove(name), + 'config-bundle', + name => name + ); +} diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 85da20b00..b0c01b835 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -7,6 +7,7 @@ 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'; @@ -30,6 +31,7 @@ type FlowState = | { name: 'evaluator-wizard' } | { name: 'online-eval-wizard' } | { name: 'policy-wizard' } + | { name: 'config-bundle-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -201,6 +203,9 @@ export function AddFlow(props: AddFlowProps) { case 'policy': setFlow({ name: 'policy-wizard' }); break; + case 'config-bundle': + setFlow({ name: 'config-bundle-wizard' }); + break; } }, []); @@ -434,6 +439,19 @@ export function AddFlow(props: AddFlowProps) { ); } + // Configuration bundle wizard + if (flow.name === 'config-bundle-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( ({ 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..af7856a2c --- /dev/null +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx @@ -0,0 +1,88 @@ +import { ErrorPrompt } from '../../components'; +import { useCreateConfigBundle, useExistingConfigBundleNames } from '../../hooks/useCreateConfigBundle'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddConfigBundleScreen } from './AddConfigBundleScreen'; +import type { AddConfigBundleConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { 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: 'create-wizard' }); + + 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 || undefined, + commitMessage: config.commitMessage || undefined, + }).then(result => { + if (result.ok) { + setFlow({ name: 'create-success', bundleName: result.bundleName }); + return; + } + setFlow({ name: 'error', message: result.error }); + }); + }, + [createConfigBundle] + ); + + 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/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx new file mode 100644 index 000000000..d12d9e12e --- /dev/null +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -0,0 +1,197 @@ +import { ComponentConfigurationMapSchema, 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, ComponentInputMethod } from './types'; +import { CONFIG_BUNDLE_STEP_LABELS, INPUT_METHOD_OPTIONS } from './types'; +import { useAddConfigBundleWizard } from './useAddConfigBundleWizard'; +import { existsSync, readFileSync } from 'fs'; +import React, { useMemo } from 'react'; + +interface AddConfigBundleScreenProps { + onComplete: (config: AddConfigBundleConfig) => void; + onExit: () => void; + existingBundleNames: string[]; +} + +function validateComponentsJson(value: string): string | true { + try { + const parsed: unknown = JSON.parse(value); + ComponentConfigurationMapSchema.parse(parsed); + return true; + } catch (err) { + if (err instanceof SyntaxError) { + return 'Invalid JSON syntax'; + } + return 'Must be a map of component ARN to { configuration: { ... } }'; + } +} + +function validateComponentsFile(value: string): string | true { + if (!value.trim()) return 'File path is required'; + if (!existsSync(value.trim())) return `File not found: ${value.trim()}`; + try { + const raw = readFileSync(value.trim(), 'utf-8'); + return validateComponentsJson(raw); + } catch { + return 'Failed to read file'; + } +} + +export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames }: AddConfigBundleScreenProps) { + const wizard = useAddConfigBundleWizard(); + + const inputMethodItems: SelectableItem[] = useMemo( + () => INPUT_METHOD_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const isNameStep = wizard.step === 'name'; + const isDescriptionStep = wizard.step === 'description'; + const isInputMethodStep = wizard.step === 'inputMethod'; + const isComponentsStep = wizard.step === 'components'; + const isBranchNameStep = wizard.step === 'branchName'; + const isCommitMessageStep = wizard.step === 'commitMessage'; + const isConfirmStep = wizard.step === 'confirm'; + + const inputMethodNav = useListNavigation({ + items: inputMethodItems, + onSelect: item => wizard.setInputMethod(item.id as ComponentInputMethod), + onExit: () => wizard.goBack(), + isActive: isInputMethodStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = isInputMethodStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ( + + ); + + const componentsPreview = + wizard.config.inputMethod === 'file' + ? wizard.config.componentsRaw + : Object.keys(wizard.config.components).length > 0 + ? `${Object.keys(wizard.config.components).length} component(s)` + : ''; + + return ( + + + {isNameStep && ( + !existingBundleNames.includes(value) || 'Bundle name already exists'} + /> + )} + + {isDescriptionStep && ( + wizard.goBack()} + /> + )} + + {isInputMethodStep && ( + + )} + + {isComponentsStep && wizard.config.inputMethod === 'inline' && ( + { + const parsed = JSON.parse(value) as Record }>; + wizard.setComponents(parsed, value); + }} + onCancel={() => wizard.goBack()} + customValidation={validateComponentsJson} + /> + )} + + {isComponentsStep && wizard.config.inputMethod === 'file' && ( + { + const raw = readFileSync(value.trim(), 'utf-8'); + const parsed = JSON.parse(raw) as Record }>; + wizard.setComponents(parsed, value.trim()); + }} + onCancel={() => wizard.goBack()} + customValidation={validateComponentsFile} + /> + )} + + {isBranchNameStep && ( + wizard.goBack()} + /> + )} + + {isCommitMessageStep && ( + wizard.goBack()} + /> + )} + + {isConfirmStep && ( + + )} + + + ); +} 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..a2c88a29d --- /dev/null +++ b/src/cli/tui/screens/config-bundle/types.ts @@ -0,0 +1,42 @@ +import type { ComponentConfigurationMap } from '../../../../schema'; + +// ───────────────────────────────────────────────────────────────────────────── +// Config Bundle Wizard Types +// ───────────────────────────────────────────────────────────────────────────── + +export type AddConfigBundleStep = + | 'name' + | 'description' + | 'inputMethod' + | 'components' + | 'branchName' + | 'commitMessage' + | 'confirm'; + +export type ComponentInputMethod = 'inline' | 'file'; + +export interface AddConfigBundleConfig { + name: string; + description: string; + inputMethod: ComponentInputMethod; + components: ComponentConfigurationMap; + /** Raw text entered by user (JSON string or file path). */ + componentsRaw: string; + branchName: string; + commitMessage: string; +} + +export const CONFIG_BUNDLE_STEP_LABELS: Record = { + name: 'Name', + description: 'Description', + inputMethod: 'Input', + components: 'Components', + branchName: 'Branch', + commitMessage: 'Message', + confirm: 'Confirm', +}; + +export const INPUT_METHOD_OPTIONS = [ + { id: 'inline', title: 'Inline JSON', description: 'Enter component configurations as JSON' }, + { id: 'file', title: 'File path', description: 'Load from a JSON file on disk' }, +] 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..c68a983be --- /dev/null +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -0,0 +1,116 @@ +import type { ComponentConfigurationMap } from '../../../../schema'; +import type { AddConfigBundleConfig, AddConfigBundleStep, ComponentInputMethod } from './types'; +import { useCallback, useState } from 'react'; + +const ALL_STEPS: AddConfigBundleStep[] = [ + 'name', + 'description', + 'inputMethod', + 'components', + 'branchName', + 'commitMessage', + 'confirm', +]; + +function getDefaultConfig(): AddConfigBundleConfig { + return { + name: '', + description: '', + inputMethod: 'inline', + components: {}, + componentsRaw: '', + branchName: '', + 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 nextStep = useCallback((currentStep: AddConfigBundleStep): AddConfigBundleStep | undefined => { + const idx = ALL_STEPS.indexOf(currentStep); + return ALL_STEPS[idx + 1]; + }, []); + + const setName = useCallback( + (name: string) => { + setConfig(c => ({ ...c, name })); + const next = nextStep('name'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setDescription = useCallback( + (description: string) => { + setConfig(c => ({ ...c, description })); + const next = nextStep('description'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setInputMethod = useCallback( + (inputMethod: ComponentInputMethod) => { + setConfig(c => ({ ...c, inputMethod })); + const next = nextStep('inputMethod'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setComponents = useCallback( + (components: ComponentConfigurationMap, raw: string) => { + setConfig(c => ({ ...c, components, componentsRaw: raw })); + const next = nextStep('components'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setBranchName = useCallback( + (branchName: string) => { + setConfig(c => ({ ...c, branchName })); + const next = nextStep('branchName'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setCommitMessage = useCallback( + (commitMessage: string) => { + setConfig(c => ({ ...c, commitMessage })); + const next = nextStep('commitMessage'); + if (next) setStep(next); + }, + [nextStep] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('name'); + }, []); + + return { + config, + step, + steps: ALL_STEPS, + currentIndex, + goBack, + setName, + setDescription, + setInputMethod, + setComponents, + setBranchName, + setCommitMessage, + 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 f4f85acd2..fe583bae6 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -2,6 +2,7 @@ import type { RemovableGatewayTarget, RemovalPreview } from '../../../operations import { ErrorPrompt, Panel, Screen } from '../../components'; import { useRemovableAgents, + useRemovableConfigBundles, useRemovableEvaluators, useRemovableGatewayTargets, useRemovableGateways, @@ -12,6 +13,7 @@ import { useRemovablePolicyEngines, useRemovalPreview, useRemoveAgent, + useRemoveConfigBundle, useRemoveEvaluator, useRemoveGateway, useRemoveGatewayTarget, @@ -23,6 +25,7 @@ import { } from '../../hooks/useRemove'; import { RemoveAgentScreen } from './RemoveAgentScreen'; import { RemoveAllScreen } from './RemoveAllScreen'; +import { RemoveConfigBundleScreen } from './RemoveConfigBundleScreen'; import { RemoveConfirmScreen } from './RemoveConfirmScreen'; import { RemoveEvaluatorScreen } from './RemoveEvaluatorScreen'; import { RemoveGatewayScreen } from './RemoveGatewayScreen'; @@ -50,6 +53,7 @@ type FlowState = | { name: 'select-online-eval' } | { name: 'select-policy-engine' } | { name: 'select-policy' } + | { name: 'select-config-bundle' } | { 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-config-bundle'; bundleName: 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: 'config-bundle-success'; bundleName: string; logFilePath?: string } | { name: 'remove-all' } | { name: 'error'; message: string }; @@ -90,7 +96,8 @@ interface RemoveFlowProps { | 'evaluator' | 'online-eval' | 'policy-engine' - | 'policy'; + | 'policy' + | 'config-bundle'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; } @@ -124,6 +131,8 @@ export function RemoveFlow({ return { name: 'select-policy-engine' }; case 'policy': return { name: 'select-policy' }; + case 'config-bundle': + return { name: 'select-config-bundle' }; default: return { name: 'select' }; } @@ -148,6 +157,11 @@ export function RemoveFlow({ refresh: refreshPolicyEngines, } = useRemovablePolicyEngines(); const { policies, isLoading: isLoadingPolicies, refresh: refreshPolicies } = useRemovablePolicies(); + const { + configBundles, + isLoading: isLoadingConfigBundles, + refresh: refreshConfigBundles, + } = useRemovableConfigBundles(); // Check if any data is still loading const isLoading = @@ -159,7 +173,8 @@ export function RemoveFlow({ isLoadingEvaluators || isLoadingOnlineEvals || isLoadingPolicyEngines || - isLoadingPolicies; + isLoadingPolicies || + isLoadingConfigBundles; // Preview hook const { @@ -172,6 +187,7 @@ export function RemoveFlow({ loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, + loadConfigBundlePreview, 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: removeConfigBundleOp, reset: resetRemoveConfigBundle } = useRemoveConfigBundle(); // Track pending result state const pendingResultRef = useRef(null); @@ -215,6 +232,7 @@ export function RemoveFlow({ 'online-eval-success', 'policy-engine-success', 'policy-success', + 'config-bundle-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -254,6 +272,9 @@ export function RemoveFlow({ case 'policy': setFlow({ name: 'select-policy' }); break; + case 'config-bundle': + setFlow({ name: 'select-config-bundle' }); + break; case 'all': setFlow({ name: 'remove-all' }); break; @@ -464,6 +485,28 @@ 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] + ); + // 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 'config-bundle': + void handleSelectConfigBundle(initialResourceName); + break; } }, 0); }, [ @@ -514,6 +560,7 @@ export function RemoveFlow({ handleSelectOnlineEval, handleSelectPolicyEngine, handleSelectPolicy, + handleSelectConfigBundle, ]); // Confirm handlers - pass preview for logging @@ -661,6 +708,22 @@ 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 resetAll = useCallback(() => { resetPreview(); resetRemoveAgent(); @@ -672,6 +735,7 @@ export function RemoveFlow({ resetRemoveOnlineEval(); resetRemovePolicyEngine(); resetRemovePolicy(); + resetRemoveConfigBundle(); }, [ resetPreview, resetRemoveAgent, @@ -683,6 +747,7 @@ export function RemoveFlow({ resetRemoveOnlineEval, resetRemovePolicyEngine, resetRemovePolicy, + resetRemoveConfigBundle, ]); const refreshAll = useCallback(async () => { @@ -696,6 +761,7 @@ export function RemoveFlow({ refreshOnlineEvals(), refreshPolicyEngines(), refreshPolicies(), + refreshConfigBundles(), ]); }, [ refreshAgents, @@ -707,6 +773,7 @@ export function RemoveFlow({ refreshOnlineEvals, refreshPolicyEngines, refreshPolicies, + refreshConfigBundles, ]); // 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} + configBundleCount={configBundles.length} /> ); } @@ -861,6 +929,19 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-config-bundle') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectConfigBundle(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + // Confirmation screens if (flow.name === 'confirm-agent') { return ( @@ -961,6 +1042,17 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-config-bundle') { + return ( + void handleConfirmConfigBundle(flow.bundleName, flow.preview)} + onCancel={() => setFlow({ name: 'select-config-bundle' })} + /> + ); + } + // Success screens if (flow.name === 'agent-success') { return ( @@ -1106,6 +1198,22 @@ export function RemoveFlow({ ); } + if (flow.name === 'config-bundle-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/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index f562b0ff6..ff102af07 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: 'config-bundle', title: 'Configuration Bundle', description: 'Remove a configuration bundle' }, { 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 configuration bundles available for removal */ + configBundleCount: number; } export function RemoveScreen({ @@ -52,6 +55,7 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + configBundleCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { @@ -113,6 +117,12 @@ 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 'all': // 'all' is always available break; @@ -130,6 +140,7 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + configBundleCount, ]); 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..ff144ade1 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} + configBundleCount={1} /> ); @@ -51,6 +52,7 @@ describe('RemoveScreen', () => { onlineEvalCount={0} policyEngineCount={0} policyCount={0} + configBundleCount={0} /> ); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index c83d9d40b..be0882042 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -9,6 +9,7 @@ import { isReservedProjectName } from '../constants'; import { AgentEnvSpecSchema } from './agent-env'; import { AgentCoreGatewaySchema, AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema } from './mcp'; +import { ConfigBundleSchema } from './primitives/config-bundle'; import { EvaluationLevelSchema, EvaluatorConfigSchema, EvaluatorNameSchema } from './primitives/evaluator'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, @@ -35,6 +36,9 @@ export type { OnlineEvalConfig } from './primitives/online-eval-config'; export { OnlineEvalConfigSchema, OnlineEvalConfigNameSchema } from './primitives/online-eval-config'; export type { EvaluationLevel, EvaluatorConfig, LlmAsAJudgeConfig, 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'; @@ -273,6 +277,16 @@ 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}` + ) + ), }) .strict() .superRefine((spec, ctx) => { diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 2454d6b09..28e5543ea 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -168,6 +168,18 @@ export const OnlineEvalDeployedStateSchema = z.object({ 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; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -180,6 +192,7 @@ 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(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), stackName: z.string().optional(), diff --git a/src/schema/schemas/primitives/config-bundle.ts b/src/schema/schemas/primitives/config-bundle.ts new file mode 100644 index 000000000..33eee380c --- /dev/null +++ b/src/schema/schemas/primitives/config-bundle.ts @@ -0,0 +1,48 @@ +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 `{{agent:}}` 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({ + type: z.literal('ConfigurationBundle'), + name: ConfigBundleNameSchema, + 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; From c190b0c1d5eb7d09d8dbb332964dae7fb9836b45 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 2 Apr 2026 02:18:31 -0400 Subject: [PATCH 06/64] 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. --- src/cli/aws/agentcore-config-bundles.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index 17d27c8ed..4a52ff6e2 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -202,8 +202,7 @@ async function signedRequest(options: { }); const credentials = getCredentialProvider() ?? defaultProvider(); - const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); - const service = stage === 'beta' || stage === 'gamma' ? 'bedrock-agentcore' : 'bedrock-agentcore-control'; + const service = 'bedrock-agentcore'; const signer = new SignatureV4({ service, region, From 4781dedd61731d33c6f38f01e35b041574781e1a Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 2 Apr 2026 02:59:52 -0400 Subject: [PATCH 07/64] 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 --- src/cli/aws/agentcore-config-bundles.ts | 2 +- .../deploy/post-deploy-config-bundles.ts | 72 +++++++++++-------- src/cli/primitives/ConfigBundlePrimitive.ts | 2 +- .../config-bundle/AddConfigBundleFlow.tsx | 4 +- .../config-bundle/AddConfigBundleScreen.tsx | 10 +-- .../config-bundle/useAddConfigBundleWizard.ts | 2 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 34 +++++++++ 7 files changed, 88 insertions(+), 38 deletions(-) diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index 4a52ff6e2..01586230b 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -274,7 +274,7 @@ export async function getConfigurationBundle( export async function updateConfigurationBundle( options: UpdateConfigurationBundleOptions ): Promise { - const body: Record = {}; + 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; diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index 446398815..c257a2a0c 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -2,6 +2,7 @@ import type { AgentCoreProjectSpec, ConfigBundleDeployedState } from '../../../s import { createConfigurationBundle, deleteConfigurationBundle, + getConfigurationBundle, listConfigurationBundles, updateConfigurationBundle, } from '../../aws/agentcore-config-bundles'; @@ -56,45 +57,58 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr // Create or update bundles from the spec for (const bundleSpec of projectSpec.configBundles) { try { + // Try to update if we have an existing bundle ID const existingBundle = existingBundles?.[bundleSpec.name]; + let updated = false; if (existingBundle) { - // Update existing bundle (creates a new version) - const result = await updateConfigurationBundle({ - region, - bundleId: existingBundle.bundleId, - description: bundleSpec.description, - components: bundleSpec.components as ComponentConfigurationMap, - branchName: bundleSpec.branchName, - commitMessage: bundleSpec.commitMessage, - }); - - 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 { + try { + const result = await updateConfigurationBundle({ + region, + bundleId: existingBundle.bundleId, + description: bundleSpec.description, + components: bundleSpec.components as ComponentConfigurationMap, + parentVersionIds: [existingBundle.versionId], + branchName: bundleSpec.branchName ?? 'main', + 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, bundleSpec.name); if (existingByName) { - // Update the existing one and track it + // Fetch current version to use as parent + const current = await getConfigurationBundle({ region, bundleId: existingByName.bundleId }); const result = await updateConfigurationBundle({ region, bundleId: existingByName.bundleId, description: bundleSpec.description, components: bundleSpec.components as ComponentConfigurationMap, - branchName: bundleSpec.branchName, - commitMessage: bundleSpec.commitMessage, + parentVersionIds: [current.versionId], + branchName: bundleSpec.branchName ?? 'main', + commitMessage: bundleSpec.commitMessage ?? `Update ${bundleSpec.name}`, }); configBundles[bundleSpec.name] = { @@ -117,8 +131,8 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr bundleName: bundleSpec.name, description: bundleSpec.description, components: bundleSpec.components as ComponentConfigurationMap, - branchName: bundleSpec.branchName, - commitMessage: bundleSpec.commitMessage, + branchName: bundleSpec.branchName ?? 'main', + commitMessage: bundleSpec.commitMessage ?? `Create ${bundleSpec.name}`, }); configBundles[bundleSpec.name] = { diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index 9a631429f..fcc5d8327 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -220,7 +220,7 @@ export class ConfigBundlePrimitive extends BasePrimitive { if (result.ok) { setFlow({ name: 'create-success', bundleName: result.bundleName }); diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index d12d9e12e..475b27deb 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -161,7 +161,8 @@ export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames {isBranchNameStep && ( )} diff --git a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index c68a983be..a64325830 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -19,7 +19,7 @@ function getDefaultConfig(): AddConfigBundleConfig { inputMethod: 'inline', components: {}, componentsRaw: '', - branchName: '', + branchName: 'main', commitMessage: '', }; } diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 0da441d0c..767dbccb5 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -15,6 +15,7 @@ import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from import { ExecLogger } from '../../../logging'; import { performStackTeardown, setupTransactionSearch } from '../../../operations/deploy'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; +import { setupConfigBundles } from '../../../operations/deploy/post-deploy-config-bundles'; import { type StackDiffSummary, type Step, @@ -303,6 +304,39 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }); await configIO.writeDeployedState(deployedState); + // Post-deploy: Create/update configuration bundles + const configBundleSpecs = ctx.projectSpec.configBundles ?? []; + if (configBundleSpecs.length > 0) { + try { + const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; + const configBundleResult = await setupConfigBundles({ + region: target.region, + projectSpec: ctx.projectSpec, + 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'); + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.log(`Config bundle setup failed: ${message}`, 'warn'); + } + } + // Query gateway target sync statuses (non-blocking) const allStatuses: { name: string; status: string }[] = []; for (const [, gateway] of Object.entries(gateways)) { From f1e34d23a0dc34310b4ba5a094baf086e537c270 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 2 Apr 2026 03:08:00 -0400 Subject: [PATCH 08/64] fix: use nullish coalescing for branchName default --- src/cli/primitives/ConfigBundlePrimitive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index fcc5d8327..4b89b7d82 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -220,7 +220,7 @@ export class ConfigBundlePrimitive extends BasePrimitive Date: Thu, 2 Apr 2026 04:21:01 -0400 Subject: [PATCH 09/64] 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 --- src/cli/cli.ts | 7 +- src/cli/commands/edit/command.tsx | 40 ++++ src/cli/commands/edit/index.ts | 1 + src/cli/commands/index.ts | 1 + .../deploy/post-deploy-config-bundles.ts | 162 +++++++++++---- src/cli/primitives/ConfigBundlePrimitive.ts | 146 ++++++++++++- src/cli/primitives/index.ts | 1 + src/cli/tui/App.tsx | 16 ++ src/cli/tui/hooks/useEditConfigBundle.ts | 42 ++++ .../config-bundle/AddConfigBundleFlow.tsx | 2 +- .../config-bundle/AddConfigBundleScreen.tsx | 2 +- .../config-bundle/EditConfigBundleFlow.tsx | 97 +++++++++ .../config-bundle/EditConfigBundleScreen.tsx | 195 ++++++++++++++++++ src/cli/tui/screens/config-bundle/index.ts | 1 + .../config-bundle/useAddConfigBundleWizard.ts | 2 +- .../useEditConfigBundleWizard.ts | 130 ++++++++++++ 16 files changed, 798 insertions(+), 47 deletions(-) create mode 100644 src/cli/commands/edit/command.tsx create mode 100644 src/cli/commands/edit/index.ts create mode 100644 src/cli/tui/hooks/useEditConfigBundle.ts create mode 100644 src/cli/tui/screens/config-bundle/EditConfigBundleFlow.tsx create mode 100644 src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx create mode 100644 src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 848e05e6f..fed5f3f00 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -2,6 +2,7 @@ import { registerAdd } from './commands/add'; import { registerCreate } from './commands/create'; import { registerDeploy } from './commands/deploy'; import { registerDev } from './commands/dev'; +import { registerEdit } from './commands/edit'; import { registerEval } from './commands/eval'; import { registerFetch } from './commands/fetch'; import { registerHelp } from './commands/help'; @@ -18,7 +19,7 @@ import { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; import { PACKAGE_VERSION } from './constants'; -import { ALL_PRIMITIVES } from './primitives'; +import { ALL_PRIMITIVES, configBundlePrimitive } from './primitives'; import { App } from './tui/App'; import { LayoutProvider } from './tui/context'; import { COMMAND_DESCRIPTIONS } from './tui/copy'; @@ -151,11 +152,15 @@ export function registerCommands(program: Command) { registerTraces(program); registerUpdate(program); registerValidate(program); + const editCmd = registerEdit(program); // Register primitive subcommands (add agent, remove agent, add memory, etc.) for (const primitive of ALL_PRIMITIVES) { primitive.registerCommands(addCmd, removeCmd); } + + // Register edit subcommands + configBundlePrimitive.registerEditCommand(editCmd); } export const main = async (argv: string[]) => { diff --git a/src/cli/commands/edit/command.tsx b/src/cli/commands/edit/command.tsx new file mode 100644 index 000000000..677bb2bf9 --- /dev/null +++ b/src/cli/commands/edit/command.tsx @@ -0,0 +1,40 @@ +import { requireProject } from '../../tui/guards'; +import { EditConfigBundleFlow } from '../../tui/screens/config-bundle/EditConfigBundleFlow'; +import type { Command } from '@commander-js/extra-typings'; +import { render } from 'ink'; +import React from 'react'; + +export function registerEdit(program: Command): Command { + const editCmd = program + .command('edit') + .description('Edit AgentCore resources') + .showHelpAfterError() + .showSuggestionAfterError(); + + // Catch-all argument for invalid subcommands + editCmd.argument('[subcommand]').action((subcommand: string | undefined, _options, cmd) => { + if (subcommand) { + console.error(`error: '${subcommand}' is not a valid subcommand.`); + cmd.outputHelp(); + process.exit(1); + } + + requireProject(); + + const { clear, unmount } = render( + { + clear(); + unmount(); + }} + onBack={() => { + clear(); + unmount(); + }} + /> + ); + }); + + return editCmd; +} diff --git a/src/cli/commands/edit/index.ts b/src/cli/commands/edit/index.ts new file mode 100644 index 000000000..3dbf88c0f --- /dev/null +++ b/src/cli/commands/edit/index.ts @@ -0,0 +1 @@ +export { registerEdit } from './command'; diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index c8c1bd68b..a5bf08475 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,6 +1,7 @@ // Command registrations export { registerAdd } from './add'; export { registerDeploy } from './deploy'; +export { registerEdit } from './edit'; export { registerDev } from './dev'; export { registerCreate } from './create'; export { registerEval } from './eval'; diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index c257a2a0c..13a293c75 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -2,7 +2,8 @@ import type { AgentCoreProjectSpec, ConfigBundleDeployedState } from '../../../s import { createConfigurationBundle, deleteConfigurationBundle, - getConfigurationBundle, + getConfigurationBundleVersion, + listConfigurationBundleVersions, listConfigurationBundles, updateConfigurationBundle, } from '../../aws/agentcore-config-bundles'; @@ -63,30 +64,58 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr if (existingBundle) { try { - const result = await updateConfigurationBundle({ + // Fetch the exact version we know about — avoids branch-not-found errors + const current = await getConfigurationBundleVersion({ region, bundleId: existingBundle.bundleId, - description: bundleSpec.description, - components: bundleSpec.components as ComponentConfigurationMap, - parentVersionIds: [existingBundle.versionId], - branchName: bundleSpec.branchName ?? 'main', - commitMessage: bundleSpec.commitMessage ?? `Update ${bundleSpec.name}`, + versionId: existingBundle.versionId, }); + const componentsChanged = !deepEqual(current.components, bundleSpec.components); + const descriptionChanged = (bundleSpec.description ?? undefined) !== (current.description ?? undefined); - configBundles[bundleSpec.name] = { - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }; + 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}`, + }); - results.push({ - bundleName: bundleSpec.name, - status: 'updated', - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }); - updated = true; + 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); @@ -99,39 +128,69 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr const existingByName = await findBundleByName(region, bundleSpec.name); if (existingByName) { - // Fetch current version to use as parent - const current = await getConfigurationBundle({ region, bundleId: existingByName.bundleId }); - const result = await updateConfigurationBundle({ + // Fetch versions and pick the newest — avoids branch-not-found errors from getConfigurationBundle + const versions = await listConfigurationBundleVersions({ region, bundleId: existingByName.bundleId, - description: bundleSpec.description, - components: bundleSpec.components as ComponentConfigurationMap, - parentVersionIds: [current.versionId], - branchName: bundleSpec.branchName ?? 'main', - commitMessage: bundleSpec.commitMessage ?? `Update ${bundleSpec.name}`, }); + 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); - configBundles[bundleSpec.name] = { - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }; + 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}`, + }); - results.push({ - bundleName: bundleSpec.name, - status: 'updated', - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }); + 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 + // Create new — omit branchName if not in spec so the API uses its default const result = await createConfigurationBundle({ region, bundleName: bundleSpec.name, description: bundleSpec.description, components: bundleSpec.components as ComponentConfigurationMap, - branchName: bundleSpec.branchName ?? 'main', + branchName: bundleSpec.branchName, commitMessage: bundleSpec.commitMessage ?? `Create ${bundleSpec.name}`, }); @@ -204,3 +263,22 @@ async function findBundleByName(region: string, bundleName: string): Promise<{ b 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])); +} diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index 4b89b7d82..a4efeb2cd 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -16,6 +16,19 @@ export interface AddConfigBundleOptions { commitMessage?: string; } +export interface EditConfigBundleOptions { + /** Name of the existing bundle to edit. */ + bundleName: string; + /** New description (omit to keep existing). */ + description?: string; + /** Replacement components map. */ + components: Record }>; + /** Branch name for the new version. */ + branchName?: string; + /** Commit message for the new version. */ + commitMessage?: string; +} + export type RemovableConfigBundle = RemovableResource; /** @@ -206,6 +219,137 @@ export class ConfigBundlePrimitive extends BasePrimitive> { + try { + const project = await this.readProjectSpec(); + const index = project.configBundles.findIndex(b => b.name === options.bundleName); + if (index === -1) { + return { success: false, error: `Configuration bundle "${options.bundleName}" not found.` }; + } + + const existing = project.configBundles[index]!; + project.configBundles[index] = { + ...existing, + components: options.components, + ...(options.description !== undefined && { description: options.description }), + ...(options.branchName !== undefined && { branchName: options.branchName }), + ...(options.commitMessage !== undefined && { commitMessage: options.commitMessage }), + }; + + await this.writeProjectSpec(project); + return { success: true, bundleName: options.bundleName }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + registerEditCommand(editCmd: Command): void { + editCmd + .command(this.kind) + .description('Edit a configuration bundle in the project') + .option('--bundle ', 'Bundle name to edit') + .option('--description ', 'New bundle description') + .option('--components ', 'Components map as inline JSON') + .option('--components-file ', 'Path to components JSON file') + .option('--branch ', 'Branch name for versioning') + .option('--message ', 'Commit message for this version') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + bundle?: string; + description?: string; + components?: string; + componentsFile?: string; + branch?: string; + message?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.bundle || 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.bundle) { + fail('--bundle 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.edit({ + bundleName: cliOptions.bundle!, + description: cliOptions.description, + components, + branchName: cliOptions.branch, + commitMessage: cliOptions.message, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Updated configuration bundle '${result.bundleName}'`); + } else { + console.error(result.error); + } + process.exit(result.success ? 0 : 1); + } else { + // TUI fallback + const [{ render }, { default: React }, { EditConfigBundleFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/config-bundle/EditConfigBundleFlow'), + ]); + const { clear, unmount } = render( + React.createElement(EditConfigBundleFlow, { + isInteractive: false, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + onBack: () => { + 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); + } + } + ); + } + addScreen(): AddScreenComponent { return null; } @@ -220,7 +364,7 @@ export class ConfigBundlePrimitive extends BasePrimitive setRoute({ name: 'help' })} + onBack={() => setRoute({ name: 'help' })} + onDev={() => setRoute({ name: 'dev' })} + onDeploy={() => setRoute({ name: 'deploy' })} + /> + ); + } + if (route.name === 'remove') { return ( }>; + branchName?: string; + commitMessage?: string; +} + +export function useEditConfigBundle() { + const [status, setStatus] = useState<{ state: 'idle' | 'loading' | 'success' | 'error'; error?: string }>({ + state: 'idle', + }); + + const editConfigBundle = useCallback(async (config: EditConfigBundleConfig) => { + setStatus({ state: 'loading' }); + try { + const result = await configBundlePrimitive.edit({ + bundleName: config.bundleName, + components: config.components, + branchName: config.branchName, + commitMessage: config.commitMessage, + }); + if (!result.success) { + throw new Error(result.error ?? 'Failed to edit configuration bundle'); + } + setStatus({ state: 'success' }); + return { ok: true as const, bundleName: config.bundleName }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to edit configuration bundle.'; + setStatus({ state: 'error', error: message }); + return { ok: false as const, error: message }; + } + }, []); + + const reset = useCallback(() => { + setStatus({ state: 'idle' }); + }, []); + + return { status, editConfigBundle, reset }; +} diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx index f0155d504..4d79d2122 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx @@ -41,7 +41,7 @@ export function AddConfigBundleFlow({ name: config.name, description: config.description || undefined, components: config.components, - branchName: config.branchName || 'main', + branchName: config.branchName || 'mainline', commitMessage: config.commitMessage || `Create ${config.name}`, }).then(result => { if (result.ok) { diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index 475b27deb..bcadf2cb0 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -188,7 +188,7 @@ export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames { label: 'Name', value: wizard.config.name }, ...(wizard.config.description ? [{ label: 'Description', value: wizard.config.description }] : []), { label: 'Components', value: componentsPreview }, - { label: 'Branch', value: wizard.config.branchName || 'main' }, + { 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/EditConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/EditConfigBundleFlow.tsx new file mode 100644 index 000000000..344505a74 --- /dev/null +++ b/src/cli/tui/screens/config-bundle/EditConfigBundleFlow.tsx @@ -0,0 +1,97 @@ +import { ErrorPrompt } from '../../components'; +import { useExistingConfigBundleNames } from '../../hooks/useCreateConfigBundle'; +import { useEditConfigBundle } from '../../hooks/useEditConfigBundle'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { EditConfigBundleScreen } from './EditConfigBundleScreen'; +import type { EditConfigBundleConfig } from './useEditConfigBundleWizard'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'edit-wizard' } + | { name: 'edit-success'; bundleName: string } + | { name: 'error'; message: string }; + +interface EditConfigBundleFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function EditConfigBundleFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: EditConfigBundleFlowProps) { + const { editConfigBundle, reset: resetEdit } = useEditConfigBundle(); + const { names: bundleNames } = useExistingConfigBundleNames(); + const [flow, setFlow] = useState({ name: 'edit-wizard' }); + + useEffect(() => { + if (!isInteractive && flow.name === 'edit-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleEditComplete = useCallback( + (config: EditConfigBundleConfig) => { + void editConfigBundle({ + bundleName: config.bundleName, + components: config.components, + branchName: config.branchName || undefined, + commitMessage: config.commitMessage || undefined, + }).then(result => { + if (result.ok) { + setFlow({ name: 'edit-success', bundleName: result.bundleName }); + return; + } + setFlow({ name: 'error', message: result.error }); + }); + }, + [editConfigBundle] + ); + + if (flow.name === 'edit-wizard') { + if (bundleNames.length === 0) { + return ( + + ); + } + + return ; + } + + if (flow.name === 'edit-success') { + return ( + + ); + } + + return ( + { + resetEdit(); + setFlow({ name: 'edit-wizard' }); + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx new file mode 100644 index 000000000..e8e064baf --- /dev/null +++ b/src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx @@ -0,0 +1,195 @@ +import { ComponentConfigurationMapSchema } 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 type { ComponentInputMethod } from './types'; +import { INPUT_METHOD_OPTIONS } from './types'; +import type { EditConfigBundleConfig } from './useEditConfigBundleWizard'; +import { EDIT_STEP_LABELS, useEditConfigBundleWizard } from './useEditConfigBundleWizard'; +import { existsSync, readFileSync } from 'fs'; +import React, { useMemo } from 'react'; + +interface EditConfigBundleScreenProps { + onComplete: (config: EditConfigBundleConfig) => void; + onExit: () => void; + /** Existing bundle names available for editing. */ + bundleNames: string[]; +} + +function validateComponentsJson(value: string): string | true { + try { + const parsed: unknown = JSON.parse(value); + ComponentConfigurationMapSchema.parse(parsed); + return true; + } catch (err) { + if (err instanceof SyntaxError) { + return 'Invalid JSON syntax'; + } + return 'Must be a map of component ARN to { configuration: { ... } }'; + } +} + +function validateComponentsFile(value: string): string | true { + if (!value.trim()) return 'File path is required'; + if (!existsSync(value.trim())) return `File not found: ${value.trim()}`; + try { + const raw = readFileSync(value.trim(), 'utf-8'); + return validateComponentsJson(raw); + } catch { + return 'Failed to read file'; + } +} + +export function EditConfigBundleScreen({ onComplete, onExit, bundleNames }: EditConfigBundleScreenProps) { + const wizard = useEditConfigBundleWizard(); + + const bundleItems: SelectableItem[] = useMemo( + () => bundleNames.map(name => ({ id: name, title: name })), + [bundleNames] + ); + + const inputMethodItems: SelectableItem[] = useMemo( + () => INPUT_METHOD_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const isSelectBundleStep = wizard.step === 'selectBundle'; + const isInputMethodStep = wizard.step === 'inputMethod'; + const isComponentsStep = wizard.step === 'components'; + const isCommitMessageStep = wizard.step === 'commitMessage'; + const isBranchNameStep = wizard.step === 'branchName'; + const isConfirmStep = wizard.step === 'confirm'; + + const bundleNav = useListNavigation({ + items: bundleItems, + onSelect: item => wizard.selectBundle(item.id), + onExit, + isActive: isSelectBundleStep, + }); + + const inputMethodNav = useListNavigation({ + items: inputMethodItems, + onSelect: item => wizard.setInputMethod(item.id as ComponentInputMethod), + onExit: () => wizard.goBack(), + isActive: isInputMethodStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = + isSelectBundleStep || isInputMethodStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ; + + const componentsPreview = + wizard.config.inputMethod === 'file' + ? wizard.config.componentsRaw + : Object.keys(wizard.config.components).length > 0 + ? `${Object.keys(wizard.config.components).length} component(s)` + : ''; + + return ( + + + {isSelectBundleStep && ( + + )} + + {isInputMethodStep && ( + + )} + + {isComponentsStep && wizard.config.inputMethod === 'inline' && ( + { + const parsed = JSON.parse(value) as Record }>; + wizard.setComponents(parsed, value); + }} + onCancel={() => wizard.goBack()} + customValidation={validateComponentsJson} + /> + )} + + {isComponentsStep && wizard.config.inputMethod === 'file' && ( + { + const raw = readFileSync(value.trim(), 'utf-8'); + const parsed = JSON.parse(raw) as Record }>; + wizard.setComponents(parsed, value.trim()); + }} + onCancel={() => wizard.goBack()} + customValidation={validateComponentsFile} + /> + )} + + {isCommitMessageStep && ( + wizard.goBack()} + /> + )} + + {isBranchNameStep && ( + wizard.goBack()} + /> + )} + + {isConfirmStep && ( + + )} + + + ); +} diff --git a/src/cli/tui/screens/config-bundle/index.ts b/src/cli/tui/screens/config-bundle/index.ts index 831a3c94e..b50efbd10 100644 --- a/src/cli/tui/screens/config-bundle/index.ts +++ b/src/cli/tui/screens/config-bundle/index.ts @@ -1 +1,2 @@ export { AddConfigBundleFlow } from './AddConfigBundleFlow'; +export { EditConfigBundleFlow } from './EditConfigBundleFlow'; diff --git a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index a64325830..e6e227bc8 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -19,7 +19,7 @@ function getDefaultConfig(): AddConfigBundleConfig { inputMethod: 'inline', components: {}, componentsRaw: '', - branchName: 'main', + branchName: 'mainline', commitMessage: '', }; } diff --git a/src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts new file mode 100644 index 000000000..20c519407 --- /dev/null +++ b/src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts @@ -0,0 +1,130 @@ +import type { ComponentConfigurationMap } from '../../../../schema'; +import type { ComponentInputMethod } from './types'; +import { useCallback, useState } from 'react'; + +export type EditConfigBundleStep = + | 'selectBundle' + | 'inputMethod' + | 'components' + | 'commitMessage' + | 'branchName' + | 'confirm'; + +const ALL_STEPS: EditConfigBundleStep[] = [ + 'selectBundle', + 'inputMethod', + 'components', + 'commitMessage', + 'branchName', + 'confirm', +]; + +export const EDIT_STEP_LABELS: Record = { + selectBundle: 'Bundle', + inputMethod: 'Input', + components: 'Components', + commitMessage: 'Message', + branchName: 'Branch', + confirm: 'Confirm', +}; + +export interface EditConfigBundleConfig { + bundleName: string; + inputMethod: ComponentInputMethod; + components: ComponentConfigurationMap; + componentsRaw: string; + commitMessage: string; + branchName: string; +} + +function getDefaultConfig(): EditConfigBundleConfig { + return { + bundleName: '', + inputMethod: 'inline', + components: {}, + componentsRaw: '', + commitMessage: '', + branchName: '', + }; +} + +export function useEditConfigBundleWizard() { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('selectBundle'); + + const currentIndex = ALL_STEPS.indexOf(step); + + const goBack = useCallback(() => { + const prevStep = ALL_STEPS[currentIndex - 1]; + if (prevStep) setStep(prevStep); + }, [currentIndex]); + + const nextStep = useCallback((currentStep: EditConfigBundleStep): EditConfigBundleStep | undefined => { + const idx = ALL_STEPS.indexOf(currentStep); + return ALL_STEPS[idx + 1]; + }, []); + + const selectBundle = useCallback( + (bundleName: string) => { + setConfig(c => ({ ...c, bundleName })); + const next = nextStep('selectBundle'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setInputMethod = useCallback( + (inputMethod: ComponentInputMethod) => { + setConfig(c => ({ ...c, inputMethod })); + const next = nextStep('inputMethod'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setComponents = useCallback( + (components: ComponentConfigurationMap, raw: string) => { + setConfig(c => ({ ...c, components, componentsRaw: raw })); + const next = nextStep('components'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setCommitMessage = useCallback( + (commitMessage: string) => { + setConfig(c => ({ ...c, commitMessage })); + const next = nextStep('commitMessage'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setBranchName = useCallback( + (branchName: string) => { + setConfig(c => ({ ...c, branchName })); + const next = nextStep('branchName'); + if (next) setStep(next); + }, + [nextStep] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('selectBundle'); + }, []); + + return { + config, + step, + steps: ALL_STEPS, + currentIndex, + goBack, + selectBundle, + setInputMethod, + setComponents, + setCommitMessage, + setBranchName, + reset, + }; +} From 00883d8a18d2a9e01391b84d449205f174dab10e Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 2 Apr 2026 04:29:33 -0400 Subject: [PATCH 10/64] 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 --- .../post-deploy-config-bundles.test.ts | 495 ++++++++++++++++++ .../ConfigBundlePrimitive.edit.test.ts | 204 ++++++++ .../useEditConfigBundleWizard.test.ts | 322 ++++++++++++ 3 files changed, 1021 insertions(+) create mode 100644 src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts create mode 100644 src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts create mode 100644 src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts 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..0ddf29fc2 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -0,0 +1,495 @@ +import { 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: Array>) { + return { 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: 'MyBundle', + 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: 'mainline' }, + }); + + 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: 'mainline', + 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: 'mainline' }, + }); + + 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: 'mainline' }, + }); + + // Spec has same keys in different order + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { + name: 'MyBundle', + type: 'ConfigurationBundle', + 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({ success: true }); + + 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 returns success false', async () => { + const existingBundles = { + OrphanBundle: { + bundleId: 'b-orphan', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-orphan', + versionId: 'v-1', + }, + }; + + mockDeleteConfigurationBundle.mockResolvedValue({ success: false, 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', + type: 'ConfigurationBundle', + 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', + type: 'ConfigurationBundle', + 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: 'mainline' }, + }); + + mockListConfigurationBundles.mockResolvedValue({ + bundles: [{ bundleId: 'b-found', bundleName: 'MyBundle' }], + }); + + 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', + type: 'ConfigurationBundle', + 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); + }); + }); +}); diff --git a/src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts b/src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts new file mode 100644 index 000000000..8faf25a57 --- /dev/null +++ b/src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts @@ -0,0 +1,204 @@ +import { ConfigBundlePrimitive } from '../ConfigBundlePrimitive.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, + findConfigRoot: () => '/fake/root', +})); + +function makeProject( + configBundles: Array<{ + type: string; + name: string; + description?: string; + components: Record }>; + branchName?: string; + commitMessage?: string; + }> = [], +) { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles, + }; +} + +const primitive = new ConfigBundlePrimitive(); + +describe('ConfigBundlePrimitive', () => { + afterEach(() => vi.clearAllMocks()); + + describe('edit', () => { + it('should successfully update components on an existing bundle', async () => { + const project = makeProject([ + { + type: 'config-bundle', + name: 'my-bundle', + description: 'original description', + components: { 'arn:old': { configuration: { key: 'old-value' } } }, + }, + ]); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const newComponents = { 'arn:new': { configuration: { key: 'new-value' } } }; + const result = await primitive.edit({ + bundleName: 'my-bundle', + components: newComponents, + }); + + expect(result).toEqual({ success: true, bundleName: 'my-bundle' }); + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.configBundles[0].components).toEqual(newComponents); + expect(written.configBundles[0].description).toBe('original description'); + }); + + it('should update description when provided', async () => { + const project = makeProject([ + { + type: 'config-bundle', + name: 'my-bundle', + description: 'old desc', + components: { 'arn:a': { configuration: { x: 1 } } }, + }, + ]); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.edit({ + bundleName: 'my-bundle', + components: { 'arn:a': { configuration: { x: 2 } } }, + description: 'new desc', + }); + + expect(result).toEqual({ success: true, bundleName: 'my-bundle' }); + + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.configBundles[0].description).toBe('new desc'); + }); + + it('should update branchName and commitMessage when provided', async () => { + const project = makeProject([ + { + type: 'config-bundle', + name: 'my-bundle', + components: { 'arn:a': { configuration: {} } }, + }, + ]); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.edit({ + bundleName: 'my-bundle', + components: { 'arn:b': { configuration: { y: 1 } } }, + branchName: 'feature-branch', + commitMessage: 'update config', + }); + + expect(result).toEqual({ success: true, bundleName: 'my-bundle' }); + + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.configBundles[0].branchName).toBe('feature-branch'); + expect(written.configBundles[0].commitMessage).toBe('update config'); + }); + + it('should return error when bundle name is not found', async () => { + const project = makeProject([ + { + type: 'config-bundle', + name: 'existing-bundle', + components: { 'arn:a': { configuration: {} } }, + }, + ]); + mockReadProjectSpec.mockResolvedValue(project); + + const result = await primitive.edit({ + bundleName: 'nonexistent-bundle', + components: { 'arn:x': { configuration: {} } }, + }); + + expect(result).toEqual({ + success: false, + error: 'Configuration bundle "nonexistent-bundle" not found.', + }); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('should preserve existing fields like type and name', async () => { + const project = makeProject([ + { + type: 'config-bundle', + name: 'my-bundle', + description: 'keep this', + components: { 'arn:old': { configuration: { a: 1 } } }, + branchName: 'main', + commitMessage: 'initial', + }, + ]); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.edit({ + bundleName: 'my-bundle', + components: { 'arn:updated': { configuration: { b: 2 } } }, + }); + + const written = mockWriteProjectSpec.mock.calls[0]![0]; + const bundle = written.configBundles[0]; + expect(bundle.type).toBe('config-bundle'); + expect(bundle.name).toBe('my-bundle'); + expect(bundle.description).toBe('keep this'); + expect(bundle.branchName).toBe('main'); + expect(bundle.commitMessage).toBe('initial'); + expect(bundle.components).toEqual({ 'arn:updated': { configuration: { b: 2 } } }); + }); + + it('should not overwrite existing description when description is undefined', async () => { + const project = makeProject([ + { + type: 'config-bundle', + name: 'my-bundle', + description: 'should remain', + components: { 'arn:a': { configuration: {} } }, + }, + ]); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.edit({ + bundleName: 'my-bundle', + components: { 'arn:b': { configuration: {} } }, + // description intentionally omitted + }); + + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.configBundles[0].description).toBe('should remain'); + }); + + it('should return error when readProjectSpec throws', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('File not found')); + + const result = await primitive.edit({ + bundleName: 'my-bundle', + components: { 'arn:a': { configuration: {} } }, + }); + + expect(result).toEqual({ success: false, error: 'File not found' }); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts b/src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts new file mode 100644 index 000000000..06e49ba08 --- /dev/null +++ b/src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from 'ink-testing-library'; +import React, { act } from 'react'; +import { + useEditConfigBundleWizard, + EDIT_STEP_LABELS, +} from '../useEditConfigBundleWizard.js'; +import type { + EditConfigBundleStep, + EditConfigBundleConfig, +} from '../useEditConfigBundleWizard.js'; + +// --------------------------------------------------------------------------- +// Helpers – a thin wrapper component that exposes hook state via a ref +// --------------------------------------------------------------------------- + +interface HookRef { + config: EditConfigBundleConfig; + step: EditConfigBundleStep; + steps: EditConfigBundleStep[]; + currentIndex: number; + goBack: () => void; + selectBundle: (name: string) => void; + setInputMethod: (method: 'inline' | 'file') => void; + setComponents: (components: Record, raw: string) => void; + setCommitMessage: (msg: string) => void; + setBranchName: (name: string) => void; + reset: () => void; +} + +function HookWrapper({ hookRef }: { hookRef: { current: HookRef | null } }) { + const hook = useEditConfigBundleWizard(); + hookRef.current = hook as unknown as HookRef; + return null; +} + +const ALL_STEPS: EditConfigBundleStep[] = [ + 'selectBundle', + 'inputMethod', + 'components', + 'commitMessage', + 'branchName', + 'confirm', +]; + +const DEFAULT_CONFIG: EditConfigBundleConfig = { + bundleName: '', + inputMethod: 'inline', + components: {}, + componentsRaw: '', + commitMessage: '', + branchName: '', +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useEditConfigBundleWizard', () => { + let hookRef: { current: HookRef | null }; + + beforeEach(() => { + hookRef = { current: null }; + render(React.createElement(HookWrapper, { hookRef })); + }); + + afterEach(() => { + cleanup(); + }); + + // 1 – Initial state + describe('initial state', () => { + it('should start on the selectBundle step', () => { + expect(hookRef.current!.step).toBe('selectBundle'); + }); + + it('should have default config values', () => { + expect(hookRef.current!.config).toEqual(DEFAULT_CONFIG); + }); + + it('should have currentIndex of 0', () => { + expect(hookRef.current!.currentIndex).toBe(0); + }); + }); + + // 10 – steps array matches ALL_STEPS + describe('steps', () => { + it('should return all steps in the correct order', () => { + expect(hookRef.current!.steps).toEqual(ALL_STEPS); + }); + }); + + // 2 – selectBundle advances to inputMethod + describe('selectBundle', () => { + it('should set bundleName and advance to inputMethod', () => { + act(() => { + hookRef.current!.selectBundle('my-bundle'); + }); + + expect(hookRef.current!.config.bundleName).toBe('my-bundle'); + expect(hookRef.current!.step).toBe('inputMethod'); + expect(hookRef.current!.currentIndex).toBe(1); + }); + }); + + // 3 – setInputMethod advances to components + describe('setInputMethod', () => { + it('should set inputMethod and advance to components', () => { + act(() => { + hookRef.current!.selectBundle('my-bundle'); + }); + act(() => { + hookRef.current!.setInputMethod('file'); + }); + + expect(hookRef.current!.config.inputMethod).toBe('file'); + expect(hookRef.current!.step).toBe('components'); + expect(hookRef.current!.currentIndex).toBe(2); + }); + }); + + // 4 – setComponents advances to commitMessage + describe('setComponents', () => { + it('should set components and componentsRaw and advance to commitMessage', () => { + act(() => { + hookRef.current!.selectBundle('my-bundle'); + }); + act(() => { + hookRef.current!.setInputMethod('inline'); + }); + + const components = { guardrail: { version: '1.0' } }; + const raw = '{"guardrail":{"version":"1.0"}}'; + + act(() => { + hookRef.current!.setComponents(components, raw); + }); + + expect(hookRef.current!.config.components).toEqual(components); + expect(hookRef.current!.config.componentsRaw).toBe(raw); + expect(hookRef.current!.step).toBe('commitMessage'); + expect(hookRef.current!.currentIndex).toBe(3); + }); + }); + + // 5 – setCommitMessage advances to branchName + describe('setCommitMessage', () => { + it('should set commitMessage and advance to branchName', () => { + act(() => { + hookRef.current!.selectBundle('b'); + }); + act(() => { + hookRef.current!.setInputMethod('inline'); + }); + act(() => { + hookRef.current!.setComponents({}, '{}'); + }); + act(() => { + hookRef.current!.setCommitMessage('update config'); + }); + + expect(hookRef.current!.config.commitMessage).toBe('update config'); + expect(hookRef.current!.step).toBe('branchName'); + expect(hookRef.current!.currentIndex).toBe(4); + }); + }); + + // 6 – setBranchName advances to confirm + describe('setBranchName', () => { + it('should set branchName and advance to confirm', () => { + act(() => { + hookRef.current!.selectBundle('b'); + }); + act(() => { + hookRef.current!.setInputMethod('inline'); + }); + act(() => { + hookRef.current!.setComponents({}, '{}'); + }); + act(() => { + hookRef.current!.setCommitMessage('msg'); + }); + act(() => { + hookRef.current!.setBranchName('feature/edit'); + }); + + expect(hookRef.current!.config.branchName).toBe('feature/edit'); + expect(hookRef.current!.step).toBe('confirm'); + expect(hookRef.current!.currentIndex).toBe(5); + }); + }); + + // 7 – goBack moves to previous step + describe('goBack', () => { + it('should move from inputMethod back to selectBundle', () => { + act(() => { + hookRef.current!.selectBundle('b'); + }); + + expect(hookRef.current!.step).toBe('inputMethod'); + + act(() => { + hookRef.current!.goBack(); + }); + + expect(hookRef.current!.step).toBe('selectBundle'); + expect(hookRef.current!.currentIndex).toBe(0); + }); + + it('should move from components back to inputMethod', () => { + act(() => { + hookRef.current!.selectBundle('b'); + }); + act(() => { + hookRef.current!.setInputMethod('inline'); + }); + + expect(hookRef.current!.step).toBe('components'); + + act(() => { + hookRef.current!.goBack(); + }); + + expect(hookRef.current!.step).toBe('inputMethod'); + expect(hookRef.current!.currentIndex).toBe(1); + }); + }); + + // 8 – goBack does nothing on first step + describe('goBack on first step', () => { + it('should remain on selectBundle when goBack is called at the start', () => { + act(() => { + hookRef.current!.goBack(); + }); + + expect(hookRef.current!.step).toBe('selectBundle'); + expect(hookRef.current!.currentIndex).toBe(0); + }); + }); + + // 9 – reset returns to initial state + describe('reset', () => { + it('should return to initial state after progressing through steps', () => { + act(() => { + hookRef.current!.selectBundle('b'); + }); + act(() => { + hookRef.current!.setInputMethod('file'); + }); + act(() => { + hookRef.current!.setComponents({ x: { v: '1' } }, '{"x":{"v":"1"}}'); + }); + + expect(hookRef.current!.step).toBe('commitMessage'); + expect(hookRef.current!.config.bundleName).toBe('b'); + + act(() => { + hookRef.current!.reset(); + }); + + expect(hookRef.current!.step).toBe('selectBundle'); + expect(hookRef.current!.currentIndex).toBe(0); + expect(hookRef.current!.config).toEqual(DEFAULT_CONFIG); + }); + }); + + // 11 – currentIndex tracks step position throughout the wizard + describe('currentIndex', () => { + it('should track step position as the wizard progresses', () => { + expect(hookRef.current!.currentIndex).toBe(0); + + act(() => { + hookRef.current!.selectBundle('b'); + }); + expect(hookRef.current!.currentIndex).toBe(1); + + act(() => { + hookRef.current!.setInputMethod('inline'); + }); + expect(hookRef.current!.currentIndex).toBe(2); + + act(() => { + hookRef.current!.setComponents({}, '{}'); + }); + expect(hookRef.current!.currentIndex).toBe(3); + + act(() => { + hookRef.current!.setCommitMessage('m'); + }); + expect(hookRef.current!.currentIndex).toBe(4); + + act(() => { + hookRef.current!.setBranchName('br'); + }); + expect(hookRef.current!.currentIndex).toBe(5); + }); + }); +}); + +// --------------------------------------------------------------------------- +// EDIT_STEP_LABELS – exported constant +// --------------------------------------------------------------------------- + +describe('EDIT_STEP_LABELS', () => { + it('should have a label for every step', () => { + for (const step of ALL_STEPS) { + expect(EDIT_STEP_LABELS[step]).toBeDefined(); + expect(typeof EDIT_STEP_LABELS[step]).toBe('string'); + } + }); + + it('should map steps to the expected labels', () => { + expect(EDIT_STEP_LABELS).toEqual({ + selectBundle: 'Bundle', + inputMethod: 'Input', + components: 'Components', + commitMessage: 'Message', + branchName: 'Branch', + confirm: 'Confirm', + }); + }); +}); From f671122d84e224932916a79f9de4d67e214ffcdb Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 2 Apr 2026 19:38:47 -0400 Subject: [PATCH 11/64] fix: address review comments --- src/cli/aws/agentcore-config-bundles.ts | 1 + src/cli/commands/deploy/actions.ts | 43 ++++++------- src/cli/commands/edit/command.tsx | 35 +++++++++-- .../post-deploy-config-bundles.test.ts | 36 +++++------ src/cli/primitives/ConfigBundlePrimitive.ts | 1 - src/cli/tui/App.tsx | 4 +- src/cli/tui/screens/edit/EditFlow.tsx | 62 +++++++++++++++++++ src/cli/tui/screens/edit/EditScreen.tsx | 30 +++++++++ src/cli/tui/screens/edit/index.ts | 2 + .../schemas/primitives/config-bundle.ts | 1 - 10 files changed, 160 insertions(+), 55 deletions(-) create mode 100644 src/cli/tui/screens/edit/EditFlow.tsx create mode 100644 src/cli/tui/screens/edit/EditScreen.tsx create mode 100644 src/cli/tui/screens/edit/index.ts diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index 01586230b..6a88cb963 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -166,6 +166,7 @@ export interface ListConfigurationBundleVersionsResult { // 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`; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 2486139cf..22e97cb78 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -444,35 +444,30 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { - try { - const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; - const configBundleResult = await setupConfigBundles({ - region: target.region, - projectSpec: context.projectSpec, - existingBundles: existingConfigBundles, - }); + const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; + const configBundleResult = await setupConfigBundles({ + region: target.region, + projectSpec: context.projectSpec, + 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); - } + // 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'); - } - } - } catch (err: unknown) { - logger.log(`Config bundle setup failed: ${getErrorMessage(err)}`, 'warn'); + if (configBundleResult.hasErrors) { + const errors = configBundleResult.results.filter(r => r.status === 'error'); + const errorMessages = errors.map(err => `"${err.bundleName}": ${err.error}`).join('; '); + throw new Error(`Config bundle setup failed: ${errorMessages}`); } } diff --git a/src/cli/commands/edit/command.tsx b/src/cli/commands/edit/command.tsx index 677bb2bf9..f0f2e9982 100644 --- a/src/cli/commands/edit/command.tsx +++ b/src/cli/commands/edit/command.tsx @@ -1,5 +1,5 @@ import { requireProject } from '../../tui/guards'; -import { EditConfigBundleFlow } from '../../tui/screens/config-bundle/EditConfigBundleFlow'; +import { EditFlow } from '../../tui/screens/edit'; import type { Command } from '@commander-js/extra-typings'; import { render } from 'ink'; import React from 'react'; @@ -11,10 +11,33 @@ export function registerEdit(program: Command): Command { .showHelpAfterError() .showSuggestionAfterError(); - // Catch-all argument for invalid subcommands - editCmd.argument('[subcommand]').action((subcommand: string | undefined, _options, cmd) => { - if (subcommand) { - console.error(`error: '${subcommand}' is not a valid subcommand.`); + editCmd + .command('config-bundle') + .description('Edit a configuration bundle') + .action(() => { + requireProject(); + + const { clear, unmount } = render( + { + clear(); + unmount(); + }} + onBack={() => { + clear(); + unmount(); + }} + /> + ); + }); + + // Default action when no subcommand is given — show resource selection + editCmd.action((_options, cmd) => { + // If extra arguments were passed, show help + if (cmd.args.length > 0) { + console.error(`error: '${cmd.args[0]}' is not a valid subcommand.`); cmd.outputHelp(); process.exit(1); } @@ -22,7 +45,7 @@ export function registerEdit(program: Command): Command { requireProject(); const { clear, unmount } = render( - { clear(); 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 index 0ddf29fc2..f0be87290 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -28,7 +28,7 @@ vi.mock('../../../aws/agentcore-config-bundles', () => ({ const REGION = 'us-west-2'; -function makeProjectSpec(configBundles: Array>) { +function makeProjectSpec(configBundles: Record[]) { return { configBundles } as any; } @@ -59,12 +59,12 @@ describe('setupConfigBundles', () => { bundleName: 'MyBundle', 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({ + expect(result.configBundles.MyBundle).toEqual({ bundleId: 'b-new', bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-new', versionId: 'v-1', @@ -113,7 +113,7 @@ describe('setupConfigBundles', () => { parentVersionIds: ['v-1'], branchName: 'mainline', commitMessage: 'Update MyBundle', - }), + }) ); expect(result.results[0]).toMatchObject({ status: 'updated', versionId: 'v-2' }); expect(result.hasErrors).toBe(false); @@ -151,7 +151,7 @@ describe('setupConfigBundles', () => { 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']); + expect(result.configBundles.MyBundle).toEqual(existingBundles.MyBundle); }); }); @@ -181,7 +181,6 @@ describe('setupConfigBundles', () => { projectSpec: makeProjectSpec([ { name: 'MyBundle', - type: 'ConfigurationBundle', components: { b: { type: 'inline', value: '2' }, a: { type: 'inline', value: '1' } }, }, ]), @@ -271,7 +270,6 @@ describe('setupConfigBundles', () => { projectSpec: makeProjectSpec([ { name: 'MyBundle', - type: 'ConfigurationBundle', components: { new: { type: 'inline', value: 'data' } }, // no branchName specified }, @@ -282,7 +280,7 @@ describe('setupConfigBundles', () => { expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( expect.objectContaining({ branchName: 'feature-branch', - }), + }) ); }); @@ -315,7 +313,6 @@ describe('setupConfigBundles', () => { projectSpec: makeProjectSpec([ { name: 'MyBundle', - type: 'ConfigurationBundle', components: { new: { type: 'inline', value: 'data' } }, branchName: 'spec-branch', }, @@ -326,7 +323,7 @@ describe('setupConfigBundles', () => { expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( expect.objectContaining({ branchName: 'spec-branch', - }), + }) ); }); }); @@ -342,16 +339,14 @@ describe('setupConfigBundles', () => { }; // 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: 'mainline' }, - }); + 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: 'mainline' }, + }); mockListConfigurationBundles.mockResolvedValue({ bundles: [{ bundleId: 'b-found', bundleName: 'MyBundle' }], @@ -372,7 +367,6 @@ describe('setupConfigBundles', () => { projectSpec: makeProjectSpec([ { name: 'MyBundle', - type: 'ConfigurationBundle', components: { new: { type: 'inline', value: 'data' } }, }, ]), diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index a4efeb2cd..bafd0c947 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -360,7 +360,6 @@ export class ConfigBundlePrimitive extends BasePrimitive setRoute({ name: 'help' })} onBack={() => setRoute({ name: 'help' })} diff --git a/src/cli/tui/screens/edit/EditFlow.tsx b/src/cli/tui/screens/edit/EditFlow.tsx new file mode 100644 index 000000000..c9a279600 --- /dev/null +++ b/src/cli/tui/screens/edit/EditFlow.tsx @@ -0,0 +1,62 @@ +import { EditConfigBundleFlow } from '../config-bundle/EditConfigBundleFlow'; +import type { EditResourceType } from './EditScreen'; +import { EditScreen } from './EditScreen'; +import React, { useState } from 'react'; + +type FlowState = { name: 'select' } | { name: 'config-bundle' }; + +interface EditFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; + initialResourceType?: EditResourceType; +} + +export function EditFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, + initialResourceType, +}: EditFlowProps) { + const getInitialState = (): FlowState => { + if (!initialResourceType) return { name: 'select' }; + switch (initialResourceType) { + case 'config-bundle': + return { name: 'config-bundle' }; + default: + return { name: 'select' }; + } + }; + + const [flow, setFlow] = useState(getInitialState); + + const handleSelectResource = (resourceType: EditResourceType) => { + switch (resourceType) { + case 'config-bundle': + setFlow({ name: 'config-bundle' }); + break; + } + }; + + if (flow.name === 'select') { + return ; + } + + if (flow.name === 'config-bundle') { + return ( + setFlow({ name: 'select' })} + onDev={onDev} + onDeploy={onDeploy} + /> + ); + } + + return null; +} diff --git a/src/cli/tui/screens/edit/EditScreen.tsx b/src/cli/tui/screens/edit/EditScreen.tsx new file mode 100644 index 000000000..6683da0f9 --- /dev/null +++ b/src/cli/tui/screens/edit/EditScreen.tsx @@ -0,0 +1,30 @@ +import type { SelectableItem } from '../../components'; +import { SelectScreen } from '../../components'; + +const EDIT_RESOURCES = [ + { id: 'config-bundle', title: 'Configuration Bundle', description: 'Edit versioned component configurations' }, +] as const; + +const EDIT_RESOURCE_ITEMS: SelectableItem[] = EDIT_RESOURCES.map(r => ({ + ...r, + disabled: false, + description: r.description, +})); + +export type EditResourceType = (typeof EDIT_RESOURCES)[number]['id']; + +interface EditScreenProps { + onSelect: (resourceType: EditResourceType) => void; + onExit: () => void; +} + +export function EditScreen({ onSelect, onExit }: EditScreenProps) { + return ( + onSelect(item.id as EditResourceType)} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/edit/index.ts b/src/cli/tui/screens/edit/index.ts new file mode 100644 index 000000000..2e8e4aa70 --- /dev/null +++ b/src/cli/tui/screens/edit/index.ts @@ -0,0 +1,2 @@ +export { EditFlow } from './EditFlow'; +export { EditScreen, type EditResourceType } from './EditScreen'; diff --git a/src/schema/schemas/primitives/config-bundle.ts b/src/schema/schemas/primitives/config-bundle.ts index 33eee380c..d4dc61f74 100644 --- a/src/schema/schemas/primitives/config-bundle.ts +++ b/src/schema/schemas/primitives/config-bundle.ts @@ -34,7 +34,6 @@ export const ComponentConfigurationMapSchema = z.record(z.string(), ComponentCon export type ComponentConfigurationMap = z.infer; export const ConfigBundleSchema = z.object({ - type: z.literal('ConfigurationBundle'), name: ConfigBundleNameSchema, description: ConfigBundleDescriptionSchema, /** Component configurations keyed by component ARN or placeholder. */ From bb013c13d1fbe476ddf4ec6127f1a1991604e84d Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 2 Apr 2026 20:01:47 -0400 Subject: [PATCH 12/64] fix: remove duplicate config-bundle subcommand from edit command --- src/cli/commands/edit/command.tsx | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/cli/commands/edit/command.tsx b/src/cli/commands/edit/command.tsx index f0f2e9982..25325277f 100644 --- a/src/cli/commands/edit/command.tsx +++ b/src/cli/commands/edit/command.tsx @@ -11,31 +11,7 @@ export function registerEdit(program: Command): Command { .showHelpAfterError() .showSuggestionAfterError(); - editCmd - .command('config-bundle') - .description('Edit a configuration bundle') - .action(() => { - requireProject(); - - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - onBack={() => { - clear(); - unmount(); - }} - /> - ); - }); - - // Default action when no subcommand is given — show resource selection editCmd.action((_options, cmd) => { - // If extra arguments were passed, show help if (cmd.args.length > 0) { console.error(`error: '${cmd.args[0]}' is not a valid subcommand.`); cmd.outputHelp(); From ebd5c0f66012877f59252f908527fc51bb460385 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:51:38 -0400 Subject: [PATCH 13/64] feat: config bundle version history CLI + TUI (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/cli/aws/agentcore-config-bundles.ts | 10 + src/cli/cli.ts | 9 +- src/cli/commands/config-bundle/command.tsx | 262 ++++++++++++++ src/cli/commands/config-bundle/index.ts | 1 + src/cli/commands/edit/command.tsx | 39 --- src/cli/commands/edit/index.ts | 1 - src/cli/commands/index.ts | 1 - .../operations/config-bundle/diff-versions.ts | 63 ++++ .../config-bundle/resolve-bundle.ts | 58 ++++ src/cli/primitives/ConfigBundlePrimitive.ts | 144 -------- .../ConfigBundlePrimitive.edit.test.ts | 204 ----------- src/cli/tui/App.tsx | 24 +- src/cli/tui/copy.ts | 1 + src/cli/tui/hooks/useEditConfigBundle.ts | 42 --- .../config-bundle-hub/ConfigBundleFlow.tsx | 60 ++++ .../ConfigBundleHubScreen.tsx | 129 +++++++ .../screens/config-bundle-hub/DiffScreen.tsx | 153 +++++++++ .../VersionHistoryScreen.tsx | 250 ++++++++++++++ .../tui/screens/config-bundle-hub/index.ts | 4 + .../config-bundle-hub/useConfigBundleHub.ts | 141 ++++++++ .../config-bundle/EditConfigBundleFlow.tsx | 97 ------ .../config-bundle/EditConfigBundleScreen.tsx | 195 ----------- .../useEditConfigBundleWizard.test.ts | 322 ------------------ src/cli/tui/screens/config-bundle/index.ts | 1 - .../useEditConfigBundleWizard.ts | 130 ------- src/cli/tui/screens/edit/EditFlow.tsx | 62 ---- src/cli/tui/screens/edit/EditScreen.tsx | 30 -- src/cli/tui/screens/edit/index.ts | 2 - 28 files changed, 1143 insertions(+), 1292 deletions(-) create mode 100644 src/cli/commands/config-bundle/command.tsx create mode 100644 src/cli/commands/config-bundle/index.ts delete mode 100644 src/cli/commands/edit/command.tsx delete mode 100644 src/cli/commands/edit/index.ts create mode 100644 src/cli/operations/config-bundle/diff-versions.ts create mode 100644 src/cli/operations/config-bundle/resolve-bundle.ts delete mode 100644 src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts delete mode 100644 src/cli/tui/hooks/useEditConfigBundle.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 delete mode 100644 src/cli/tui/screens/config-bundle/EditConfigBundleFlow.tsx delete mode 100644 src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx delete mode 100644 src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts delete mode 100644 src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts delete mode 100644 src/cli/tui/screens/edit/EditFlow.tsx delete mode 100644 src/cli/tui/screens/edit/EditScreen.tsx delete mode 100644 src/cli/tui/screens/edit/index.ts diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index 6a88cb963..2226db1d2 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -142,11 +142,18 @@ export interface GetConfigurationBundleVersionResult { // ── List Versions ─────────────────────────────────────────────────────────── +export interface ListConfigurationBundleVersionsFilter { + branchName?: string; + latestPerBranch?: boolean; + createdBy?: string[]; +} + export interface ListConfigurationBundleVersionsOptions { region: string; bundleId: string; maxResults?: number; nextToken?: string; + filter?: ListConfigurationBundleVersionsFilter; } export interface ConfigurationBundleVersionSummary { @@ -350,10 +357,13 @@ export async function listConfigurationBundleVersions( 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; diff --git a/src/cli/cli.ts b/src/cli/cli.ts index fed5f3f00..0d614950b 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,8 +1,8 @@ import { registerAdd } from './commands/add'; +import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; import { registerDeploy } from './commands/deploy'; import { registerDev } from './commands/dev'; -import { registerEdit } from './commands/edit'; import { registerEval } from './commands/eval'; import { registerFetch } from './commands/fetch'; import { registerHelp } from './commands/help'; @@ -19,7 +19,7 @@ import { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; import { PACKAGE_VERSION } from './constants'; -import { ALL_PRIMITIVES, configBundlePrimitive } from './primitives'; +import { ALL_PRIMITIVES } from './primitives'; import { App } from './tui/App'; import { LayoutProvider } from './tui/context'; import { COMMAND_DESCRIPTIONS } from './tui/copy'; @@ -152,15 +152,12 @@ export function registerCommands(program: Command) { registerTraces(program); registerUpdate(program); registerValidate(program); - const editCmd = registerEdit(program); + registerConfigBundle(program); // Register primitive subcommands (add agent, remove agent, add memory, etc.) for (const primitive of ALL_PRIMITIVES) { primitive.registerCommands(addCmd, removeCmd); } - - // Register edit subcommands - configBundlePrimitive.registerEditCommand(editCmd); } export const main = async (argv: string[]) => { diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx new file mode 100644 index 000000000..41be95427 --- /dev/null +++ b/src/cli/commands/config-bundle/command.tsx @@ -0,0 +1,262 @@ +import { getConfigurationBundleVersion, listConfigurationBundleVersions } 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'); +} + +function shortId(versionId: string): string { + return versionId.slice(0, 8); +} + +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?.length) filter.createdBy = 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('View configuration bundle version history and diffs'); + + // --- versions --- + cmd + .command('versions') + .description('List version history for a configuration bundle') + .requiredOption('--bundle ', 'Bundle name') + .option('--branch ', 'Filter by branch name') + .option('--latest-per-branch', 'Show only the latest version per branch') + .option('--created-by ', 'Filter by creator (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} {shortId(v.versionId)}{' '} + {formatTimestamp(v.versionCreatedAt)}{' '} + {message && "{message}"} + + + {isLast ? ' ' : '│'} by: {creator} + {meta?.parentVersionIds?.length ? ( + (parent: {meta.parentVersionIds.map(id => shortId(id)).join(', ')}) + ) : null} + + + ); + })} + + ))} + Full version IDs: 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') + .requiredOption('--bundle ', 'Bundle name') + .requiredOption('--from ', 'Source version ID') + .requiredOption('--to ', 'Target version ID') + .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: {shortId(result.fromVersion.versionId)} → {shortId(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); + } + }); + + 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/edit/command.tsx b/src/cli/commands/edit/command.tsx deleted file mode 100644 index 25325277f..000000000 --- a/src/cli/commands/edit/command.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { requireProject } from '../../tui/guards'; -import { EditFlow } from '../../tui/screens/edit'; -import type { Command } from '@commander-js/extra-typings'; -import { render } from 'ink'; -import React from 'react'; - -export function registerEdit(program: Command): Command { - const editCmd = program - .command('edit') - .description('Edit AgentCore resources') - .showHelpAfterError() - .showSuggestionAfterError(); - - editCmd.action((_options, cmd) => { - if (cmd.args.length > 0) { - console.error(`error: '${cmd.args[0]}' is not a valid subcommand.`); - cmd.outputHelp(); - process.exit(1); - } - - requireProject(); - - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - onBack={() => { - clear(); - unmount(); - }} - /> - ); - }); - - return editCmd; -} diff --git a/src/cli/commands/edit/index.ts b/src/cli/commands/edit/index.ts deleted file mode 100644 index 3dbf88c0f..000000000 --- a/src/cli/commands/edit/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerEdit } from './command'; diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index a5bf08475..c8c1bd68b 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,7 +1,6 @@ // Command registrations export { registerAdd } from './add'; export { registerDeploy } from './deploy'; -export { registerEdit } from './edit'; export { registerDev } from './dev'; export { registerCreate } from './create'; export { registerEval } from './eval'; 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..d1f3b97d9 --- /dev/null +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -0,0 +1,58 @@ +/** + * 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 { getConfigurationBundle, listConfigurationBundles } from '../../aws/agentcore-config-bundles'; + +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) { + return { + bundleId: bundle.bundleId, + bundleArn: bundle.bundleArn, + versionId: bundle.versionId, + region, + }; + } + } + + // Fallback: search via API + const result = await listConfigurationBundles({ region, maxResults: 100 }); + const match = result.bundles.find(b => b.bundleName === bundleName); + if (!match) { + throw new Error(`Configuration bundle "${bundleName}" not found. Has it been deployed?`); + } + + // Fetch the bundle to get the latest versionId (required by Recommendation API) + const bundle = await getConfigurationBundle({ region, bundleId: match.bundleId }); + + return { + bundleId: match.bundleId, + bundleArn: match.bundleArn, + versionId: bundle.versionId, + region, + }; +} diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index bafd0c947..a724d2add 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -16,19 +16,6 @@ export interface AddConfigBundleOptions { commitMessage?: string; } -export interface EditConfigBundleOptions { - /** Name of the existing bundle to edit. */ - bundleName: string; - /** New description (omit to keep existing). */ - description?: string; - /** Replacement components map. */ - components: Record }>; - /** Branch name for the new version. */ - branchName?: string; - /** Commit message for the new version. */ - commitMessage?: string; -} - export type RemovableConfigBundle = RemovableResource; /** @@ -219,137 +206,6 @@ export class ConfigBundlePrimitive extends BasePrimitive> { - try { - const project = await this.readProjectSpec(); - const index = project.configBundles.findIndex(b => b.name === options.bundleName); - if (index === -1) { - return { success: false, error: `Configuration bundle "${options.bundleName}" not found.` }; - } - - const existing = project.configBundles[index]!; - project.configBundles[index] = { - ...existing, - components: options.components, - ...(options.description !== undefined && { description: options.description }), - ...(options.branchName !== undefined && { branchName: options.branchName }), - ...(options.commitMessage !== undefined && { commitMessage: options.commitMessage }), - }; - - await this.writeProjectSpec(project); - return { success: true, bundleName: options.bundleName }; - } catch (err) { - return { success: false, error: getErrorMessage(err) }; - } - } - - registerEditCommand(editCmd: Command): void { - editCmd - .command(this.kind) - .description('Edit a configuration bundle in the project') - .option('--bundle ', 'Bundle name to edit') - .option('--description ', 'New bundle description') - .option('--components ', 'Components map as inline JSON') - .option('--components-file ', 'Path to components JSON file') - .option('--branch ', 'Branch name for versioning') - .option('--message ', 'Commit message for this version') - .option('--json', 'Output as JSON') - .action( - async (cliOptions: { - bundle?: string; - description?: string; - components?: string; - componentsFile?: string; - branch?: string; - message?: string; - json?: boolean; - }) => { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } - - if (cliOptions.bundle || 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.bundle) { - fail('--bundle 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.edit({ - bundleName: cliOptions.bundle!, - description: cliOptions.description, - components, - branchName: cliOptions.branch, - commitMessage: cliOptions.message, - }); - - if (cliOptions.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Updated configuration bundle '${result.bundleName}'`); - } else { - console.error(result.error); - } - process.exit(result.success ? 0 : 1); - } else { - // TUI fallback - const [{ render }, { default: React }, { EditConfigBundleFlow }] = await Promise.all([ - import('ink'), - import('react'), - import('../tui/screens/config-bundle/EditConfigBundleFlow'), - ]); - const { clear, unmount } = render( - React.createElement(EditConfigBundleFlow, { - isInteractive: false, - onExit: () => { - clear(); - unmount(); - process.exit(0); - }, - onBack: () => { - 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); - } - } - ); - } - addScreen(): AddScreenComponent { return null; } diff --git a/src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts b/src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts deleted file mode 100644 index 8faf25a57..000000000 --- a/src/cli/primitives/__tests__/ConfigBundlePrimitive.edit.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { ConfigBundlePrimitive } from '../ConfigBundlePrimitive.js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const mockReadProjectSpec = vi.fn(); -const mockWriteProjectSpec = vi.fn(); - -vi.mock('../../../lib/index.js', () => ({ - ConfigIO: class { - readProjectSpec = mockReadProjectSpec; - writeProjectSpec = mockWriteProjectSpec; - }, - findConfigRoot: () => '/fake/root', -})); - -function makeProject( - configBundles: Array<{ - type: string; - name: string; - description?: string; - components: Record }>; - branchName?: string; - commitMessage?: string; - }> = [], -) { - return { - name: 'TestProject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - configBundles, - }; -} - -const primitive = new ConfigBundlePrimitive(); - -describe('ConfigBundlePrimitive', () => { - afterEach(() => vi.clearAllMocks()); - - describe('edit', () => { - it('should successfully update components on an existing bundle', async () => { - const project = makeProject([ - { - type: 'config-bundle', - name: 'my-bundle', - description: 'original description', - components: { 'arn:old': { configuration: { key: 'old-value' } } }, - }, - ]); - mockReadProjectSpec.mockResolvedValue(project); - mockWriteProjectSpec.mockResolvedValue(undefined); - - const newComponents = { 'arn:new': { configuration: { key: 'new-value' } } }; - const result = await primitive.edit({ - bundleName: 'my-bundle', - components: newComponents, - }); - - expect(result).toEqual({ success: true, bundleName: 'my-bundle' }); - expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); - - const written = mockWriteProjectSpec.mock.calls[0]![0]; - expect(written.configBundles[0].components).toEqual(newComponents); - expect(written.configBundles[0].description).toBe('original description'); - }); - - it('should update description when provided', async () => { - const project = makeProject([ - { - type: 'config-bundle', - name: 'my-bundle', - description: 'old desc', - components: { 'arn:a': { configuration: { x: 1 } } }, - }, - ]); - mockReadProjectSpec.mockResolvedValue(project); - mockWriteProjectSpec.mockResolvedValue(undefined); - - const result = await primitive.edit({ - bundleName: 'my-bundle', - components: { 'arn:a': { configuration: { x: 2 } } }, - description: 'new desc', - }); - - expect(result).toEqual({ success: true, bundleName: 'my-bundle' }); - - const written = mockWriteProjectSpec.mock.calls[0]![0]; - expect(written.configBundles[0].description).toBe('new desc'); - }); - - it('should update branchName and commitMessage when provided', async () => { - const project = makeProject([ - { - type: 'config-bundle', - name: 'my-bundle', - components: { 'arn:a': { configuration: {} } }, - }, - ]); - mockReadProjectSpec.mockResolvedValue(project); - mockWriteProjectSpec.mockResolvedValue(undefined); - - const result = await primitive.edit({ - bundleName: 'my-bundle', - components: { 'arn:b': { configuration: { y: 1 } } }, - branchName: 'feature-branch', - commitMessage: 'update config', - }); - - expect(result).toEqual({ success: true, bundleName: 'my-bundle' }); - - const written = mockWriteProjectSpec.mock.calls[0]![0]; - expect(written.configBundles[0].branchName).toBe('feature-branch'); - expect(written.configBundles[0].commitMessage).toBe('update config'); - }); - - it('should return error when bundle name is not found', async () => { - const project = makeProject([ - { - type: 'config-bundle', - name: 'existing-bundle', - components: { 'arn:a': { configuration: {} } }, - }, - ]); - mockReadProjectSpec.mockResolvedValue(project); - - const result = await primitive.edit({ - bundleName: 'nonexistent-bundle', - components: { 'arn:x': { configuration: {} } }, - }); - - expect(result).toEqual({ - success: false, - error: 'Configuration bundle "nonexistent-bundle" not found.', - }); - expect(mockWriteProjectSpec).not.toHaveBeenCalled(); - }); - - it('should preserve existing fields like type and name', async () => { - const project = makeProject([ - { - type: 'config-bundle', - name: 'my-bundle', - description: 'keep this', - components: { 'arn:old': { configuration: { a: 1 } } }, - branchName: 'main', - commitMessage: 'initial', - }, - ]); - mockReadProjectSpec.mockResolvedValue(project); - mockWriteProjectSpec.mockResolvedValue(undefined); - - await primitive.edit({ - bundleName: 'my-bundle', - components: { 'arn:updated': { configuration: { b: 2 } } }, - }); - - const written = mockWriteProjectSpec.mock.calls[0]![0]; - const bundle = written.configBundles[0]; - expect(bundle.type).toBe('config-bundle'); - expect(bundle.name).toBe('my-bundle'); - expect(bundle.description).toBe('keep this'); - expect(bundle.branchName).toBe('main'); - expect(bundle.commitMessage).toBe('initial'); - expect(bundle.components).toEqual({ 'arn:updated': { configuration: { b: 2 } } }); - }); - - it('should not overwrite existing description when description is undefined', async () => { - const project = makeProject([ - { - type: 'config-bundle', - name: 'my-bundle', - description: 'should remain', - components: { 'arn:a': { configuration: {} } }, - }, - ]); - mockReadProjectSpec.mockResolvedValue(project); - mockWriteProjectSpec.mockResolvedValue(undefined); - - await primitive.edit({ - bundleName: 'my-bundle', - components: { 'arn:b': { configuration: {} } }, - // description intentionally omitted - }); - - const written = mockWriteProjectSpec.mock.calls[0]![0]; - expect(written.configBundles[0].description).toBe('should remain'); - }); - - it('should return error when readProjectSpec throws', async () => { - mockReadProjectSpec.mockRejectedValue(new Error('File not found')); - - const result = await primitive.edit({ - bundleName: 'my-bundle', - components: { 'arn:a': { configuration: {} } }, - }); - - expect(result).toEqual({ success: false, error: 'File not found' }); - expect(mockWriteProjectSpec).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index f2a11b788..b7fc8d833 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -5,10 +5,10 @@ import { CLI_ONLY_EXAMPLES } from './copy'; import { MissingProjectMessage, WrongDirectoryMessage, getProjectRootMismatch, projectExists } from './guards'; 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 { DevScreen } from './screens/dev/DevScreen'; -import { EditFlow } from './screens/edit'; import { EvalHubScreen, EvalScreen } from './screens/eval'; import { FetchAccessScreen } from './screens/fetch-access'; import { HelpScreen, HomeScreen } from './screens/home'; @@ -35,7 +35,6 @@ type Route = | { name: 'invoke' } | { name: 'create' } | { name: 'add' } - | { name: 'edit' } | { name: 'status' } | { name: 'remove' } | { name: 'run' } @@ -47,6 +46,7 @@ type Route = | { name: 'validate' } | { name: 'package' } | { name: 'update' } + | { name: 'config-bundle' } | { name: 'cli-only'; commandId: string }; // Commands that don't require being at the project root @@ -102,8 +102,6 @@ function AppContent() { return; } setRoute({ name: 'add' }); - } else if (id === 'edit') { - setRoute({ name: 'edit' }); } else if (id === 'remove') { setRoute({ name: 'remove' }); } else if (id === 'run') { @@ -118,6 +116,8 @@ function AppContent() { setRoute({ name: 'package' }); } else if (id === 'update') { setRoute({ name: 'update' }); + } else if (id === 'config-bundle') { + setRoute({ name: 'config-bundle' }); } }; @@ -181,18 +181,6 @@ function AppContent() { ); } - if (route.name === 'edit') { - return ( - setRoute({ name: 'help' })} - onBack={() => setRoute({ name: 'help' })} - onDev={() => setRoute({ name: 'dev' })} - onDeploy={() => setRoute({ name: 'deploy' })} - /> - ); - } - if (route.name === 'remove') { return ( setRoute({ name: 'help' })} />; } + if (route.name === 'config-bundle') { + return setRoute({ name: 'help' })} />; + } + if (route.name === 'cli-only') { const info = CLI_ONLY_EXAMPLES[route.commandId]; if (info) { diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 47e1ad08b..78a8239b8 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -49,6 +49,7 @@ export const COMMAND_DESCRIPTIONS = { import: 'Import resources from a Bedrock AgentCore Starter Toolkit project.', update: 'Check for and install CLI updates', validate: 'Validate agentcore/ config files.', + 'config-bundle': 'Manage configuration bundle versions and diffs.', } as const; /** diff --git a/src/cli/tui/hooks/useEditConfigBundle.ts b/src/cli/tui/hooks/useEditConfigBundle.ts deleted file mode 100644 index 52d035f21..000000000 --- a/src/cli/tui/hooks/useEditConfigBundle.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { configBundlePrimitive } from '../../primitives/registry'; -import { useCallback, useState } from 'react'; - -interface EditConfigBundleConfig { - bundleName: string; - components: Record }>; - branchName?: string; - commitMessage?: string; -} - -export function useEditConfigBundle() { - const [status, setStatus] = useState<{ state: 'idle' | 'loading' | 'success' | 'error'; error?: string }>({ - state: 'idle', - }); - - const editConfigBundle = useCallback(async (config: EditConfigBundleConfig) => { - setStatus({ state: 'loading' }); - try { - const result = await configBundlePrimitive.edit({ - bundleName: config.bundleName, - components: config.components, - branchName: config.branchName, - commitMessage: config.commitMessage, - }); - if (!result.success) { - throw new Error(result.error ?? 'Failed to edit configuration bundle'); - } - setStatus({ state: 'success' }); - return { ok: true as const, bundleName: config.bundleName }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to edit configuration bundle.'; - setStatus({ state: 'error', error: message }); - return { ok: false as const, error: message }; - } - }, []); - - const reset = useCallback(() => { - setStatus({ state: 'idle' }); - }, []); - - return { status, editConfigBundle, reset }; -} 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..f7ddaf902 --- /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..5927cc2eb --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx @@ -0,0 +1,153 @@ +/** + * 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'); +} + +function shortId(versionId: string): string { + return versionId.slice(0, 8); +} + +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: ${shortId(fromVersion.versionId)} → ${shortId(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..12beaa6bf --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx @@ -0,0 +1,250 @@ +/** + * 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'); +} + +function shortId(versionId: string): string { + return versionId.slice(0, 8); +} + +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 => shortId(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: ${shortId(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: {shortId(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} + + {shortId(v.versionId)} + + {formatTimestamp(v.versionCreatedAt)} + {message ? "{message}" : null} + + {meta?.parentVersionIds?.length ? ( + + {' '} + {isLast ? ' ' : '│'}{' '} + parent: {meta.parentVersionIds.map(id => shortId(id)).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..78b6cf784 --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts @@ -0,0 +1,141 @@ +/** + * Hook for the Config Bundle Hub — fetches deployed bundles + * and enriches them with version counts. + */ +import type { + ConfigurationBundleSummary, + ConfigurationBundleVersionSummary, +} from '../../../../cli/aws/agentcore-config-bundles'; +import { + listConfigurationBundleVersions, + listConfigurationBundles, +} from '../../../../cli/aws/agentcore-config-bundles'; +import { ConfigIO } from '../../../../lib'; +import { useEffect, useRef, useState } from 'react'; + +export interface BundleWithMeta extends ConfigurationBundleSummary { + 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 targets = await 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); + + const result = await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 }); + + // Enrich each bundle with version metadata + const enriched = await Promise.all( + result.bundles.map(async (bundle): Promise => { + try { + const versions = await listConfigurationBundleVersions({ + region: resolvedRegion, + bundleId: bundle.bundleId, + 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 { + ...bundle, + versionCount: versions.versions.length, + branches: [...branchSet], + lastUpdated: latestTs || undefined, + }; + } catch { + return { ...bundle, 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/EditConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/EditConfigBundleFlow.tsx deleted file mode 100644 index 344505a74..000000000 --- a/src/cli/tui/screens/config-bundle/EditConfigBundleFlow.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { ErrorPrompt } from '../../components'; -import { useExistingConfigBundleNames } from '../../hooks/useCreateConfigBundle'; -import { useEditConfigBundle } from '../../hooks/useEditConfigBundle'; -import { AddSuccessScreen } from '../add/AddSuccessScreen'; -import { EditConfigBundleScreen } from './EditConfigBundleScreen'; -import type { EditConfigBundleConfig } from './useEditConfigBundleWizard'; -import React, { useCallback, useEffect, useState } from 'react'; - -type FlowState = - | { name: 'edit-wizard' } - | { name: 'edit-success'; bundleName: string } - | { name: 'error'; message: string }; - -interface EditConfigBundleFlowProps { - isInteractive?: boolean; - onExit: () => void; - onBack: () => void; - onDev?: () => void; - onDeploy?: () => void; -} - -export function EditConfigBundleFlow({ - isInteractive = true, - onExit, - onBack, - onDev, - onDeploy, -}: EditConfigBundleFlowProps) { - const { editConfigBundle, reset: resetEdit } = useEditConfigBundle(); - const { names: bundleNames } = useExistingConfigBundleNames(); - const [flow, setFlow] = useState({ name: 'edit-wizard' }); - - useEffect(() => { - if (!isInteractive && flow.name === 'edit-success') { - onExit(); - } - }, [isInteractive, flow.name, onExit]); - - const handleEditComplete = useCallback( - (config: EditConfigBundleConfig) => { - void editConfigBundle({ - bundleName: config.bundleName, - components: config.components, - branchName: config.branchName || undefined, - commitMessage: config.commitMessage || undefined, - }).then(result => { - if (result.ok) { - setFlow({ name: 'edit-success', bundleName: result.bundleName }); - return; - } - setFlow({ name: 'error', message: result.error }); - }); - }, - [editConfigBundle] - ); - - if (flow.name === 'edit-wizard') { - if (bundleNames.length === 0) { - return ( - - ); - } - - return ; - } - - if (flow.name === 'edit-success') { - return ( - - ); - } - - return ( - { - resetEdit(); - setFlow({ name: 'edit-wizard' }); - }} - onExit={onExit} - /> - ); -} diff --git a/src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx deleted file mode 100644 index e8e064baf..000000000 --- a/src/cli/tui/screens/config-bundle/EditConfigBundleScreen.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { ComponentConfigurationMapSchema } 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 type { ComponentInputMethod } from './types'; -import { INPUT_METHOD_OPTIONS } from './types'; -import type { EditConfigBundleConfig } from './useEditConfigBundleWizard'; -import { EDIT_STEP_LABELS, useEditConfigBundleWizard } from './useEditConfigBundleWizard'; -import { existsSync, readFileSync } from 'fs'; -import React, { useMemo } from 'react'; - -interface EditConfigBundleScreenProps { - onComplete: (config: EditConfigBundleConfig) => void; - onExit: () => void; - /** Existing bundle names available for editing. */ - bundleNames: string[]; -} - -function validateComponentsJson(value: string): string | true { - try { - const parsed: unknown = JSON.parse(value); - ComponentConfigurationMapSchema.parse(parsed); - return true; - } catch (err) { - if (err instanceof SyntaxError) { - return 'Invalid JSON syntax'; - } - return 'Must be a map of component ARN to { configuration: { ... } }'; - } -} - -function validateComponentsFile(value: string): string | true { - if (!value.trim()) return 'File path is required'; - if (!existsSync(value.trim())) return `File not found: ${value.trim()}`; - try { - const raw = readFileSync(value.trim(), 'utf-8'); - return validateComponentsJson(raw); - } catch { - return 'Failed to read file'; - } -} - -export function EditConfigBundleScreen({ onComplete, onExit, bundleNames }: EditConfigBundleScreenProps) { - const wizard = useEditConfigBundleWizard(); - - const bundleItems: SelectableItem[] = useMemo( - () => bundleNames.map(name => ({ id: name, title: name })), - [bundleNames] - ); - - const inputMethodItems: SelectableItem[] = useMemo( - () => INPUT_METHOD_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), - [] - ); - - const isSelectBundleStep = wizard.step === 'selectBundle'; - const isInputMethodStep = wizard.step === 'inputMethod'; - const isComponentsStep = wizard.step === 'components'; - const isCommitMessageStep = wizard.step === 'commitMessage'; - const isBranchNameStep = wizard.step === 'branchName'; - const isConfirmStep = wizard.step === 'confirm'; - - const bundleNav = useListNavigation({ - items: bundleItems, - onSelect: item => wizard.selectBundle(item.id), - onExit, - isActive: isSelectBundleStep, - }); - - const inputMethodNav = useListNavigation({ - items: inputMethodItems, - onSelect: item => wizard.setInputMethod(item.id as ComponentInputMethod), - onExit: () => wizard.goBack(), - isActive: isInputMethodStep, - }); - - useListNavigation({ - items: [{ id: 'confirm', title: 'Confirm' }], - onSelect: () => onComplete(wizard.config), - onExit: () => wizard.goBack(), - isActive: isConfirmStep, - }); - - const helpText = - isSelectBundleStep || isInputMethodStep - ? HELP_TEXT.NAVIGATE_SELECT - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : HELP_TEXT.TEXT_INPUT; - - const headerContent = ; - - const componentsPreview = - wizard.config.inputMethod === 'file' - ? wizard.config.componentsRaw - : Object.keys(wizard.config.components).length > 0 - ? `${Object.keys(wizard.config.components).length} component(s)` - : ''; - - return ( - - - {isSelectBundleStep && ( - - )} - - {isInputMethodStep && ( - - )} - - {isComponentsStep && wizard.config.inputMethod === 'inline' && ( - { - const parsed = JSON.parse(value) as Record }>; - wizard.setComponents(parsed, value); - }} - onCancel={() => wizard.goBack()} - customValidation={validateComponentsJson} - /> - )} - - {isComponentsStep && wizard.config.inputMethod === 'file' && ( - { - const raw = readFileSync(value.trim(), 'utf-8'); - const parsed = JSON.parse(raw) as Record }>; - wizard.setComponents(parsed, value.trim()); - }} - onCancel={() => wizard.goBack()} - customValidation={validateComponentsFile} - /> - )} - - {isCommitMessageStep && ( - wizard.goBack()} - /> - )} - - {isBranchNameStep && ( - wizard.goBack()} - /> - )} - - {isConfirmStep && ( - - )} - - - ); -} diff --git a/src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts b/src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts deleted file mode 100644 index 06e49ba08..000000000 --- a/src/cli/tui/screens/config-bundle/__tests__/useEditConfigBundleWizard.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { render, cleanup } from 'ink-testing-library'; -import React, { act } from 'react'; -import { - useEditConfigBundleWizard, - EDIT_STEP_LABELS, -} from '../useEditConfigBundleWizard.js'; -import type { - EditConfigBundleStep, - EditConfigBundleConfig, -} from '../useEditConfigBundleWizard.js'; - -// --------------------------------------------------------------------------- -// Helpers – a thin wrapper component that exposes hook state via a ref -// --------------------------------------------------------------------------- - -interface HookRef { - config: EditConfigBundleConfig; - step: EditConfigBundleStep; - steps: EditConfigBundleStep[]; - currentIndex: number; - goBack: () => void; - selectBundle: (name: string) => void; - setInputMethod: (method: 'inline' | 'file') => void; - setComponents: (components: Record, raw: string) => void; - setCommitMessage: (msg: string) => void; - setBranchName: (name: string) => void; - reset: () => void; -} - -function HookWrapper({ hookRef }: { hookRef: { current: HookRef | null } }) { - const hook = useEditConfigBundleWizard(); - hookRef.current = hook as unknown as HookRef; - return null; -} - -const ALL_STEPS: EditConfigBundleStep[] = [ - 'selectBundle', - 'inputMethod', - 'components', - 'commitMessage', - 'branchName', - 'confirm', -]; - -const DEFAULT_CONFIG: EditConfigBundleConfig = { - bundleName: '', - inputMethod: 'inline', - components: {}, - componentsRaw: '', - commitMessage: '', - branchName: '', -}; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('useEditConfigBundleWizard', () => { - let hookRef: { current: HookRef | null }; - - beforeEach(() => { - hookRef = { current: null }; - render(React.createElement(HookWrapper, { hookRef })); - }); - - afterEach(() => { - cleanup(); - }); - - // 1 – Initial state - describe('initial state', () => { - it('should start on the selectBundle step', () => { - expect(hookRef.current!.step).toBe('selectBundle'); - }); - - it('should have default config values', () => { - expect(hookRef.current!.config).toEqual(DEFAULT_CONFIG); - }); - - it('should have currentIndex of 0', () => { - expect(hookRef.current!.currentIndex).toBe(0); - }); - }); - - // 10 – steps array matches ALL_STEPS - describe('steps', () => { - it('should return all steps in the correct order', () => { - expect(hookRef.current!.steps).toEqual(ALL_STEPS); - }); - }); - - // 2 – selectBundle advances to inputMethod - describe('selectBundle', () => { - it('should set bundleName and advance to inputMethod', () => { - act(() => { - hookRef.current!.selectBundle('my-bundle'); - }); - - expect(hookRef.current!.config.bundleName).toBe('my-bundle'); - expect(hookRef.current!.step).toBe('inputMethod'); - expect(hookRef.current!.currentIndex).toBe(1); - }); - }); - - // 3 – setInputMethod advances to components - describe('setInputMethod', () => { - it('should set inputMethod and advance to components', () => { - act(() => { - hookRef.current!.selectBundle('my-bundle'); - }); - act(() => { - hookRef.current!.setInputMethod('file'); - }); - - expect(hookRef.current!.config.inputMethod).toBe('file'); - expect(hookRef.current!.step).toBe('components'); - expect(hookRef.current!.currentIndex).toBe(2); - }); - }); - - // 4 – setComponents advances to commitMessage - describe('setComponents', () => { - it('should set components and componentsRaw and advance to commitMessage', () => { - act(() => { - hookRef.current!.selectBundle('my-bundle'); - }); - act(() => { - hookRef.current!.setInputMethod('inline'); - }); - - const components = { guardrail: { version: '1.0' } }; - const raw = '{"guardrail":{"version":"1.0"}}'; - - act(() => { - hookRef.current!.setComponents(components, raw); - }); - - expect(hookRef.current!.config.components).toEqual(components); - expect(hookRef.current!.config.componentsRaw).toBe(raw); - expect(hookRef.current!.step).toBe('commitMessage'); - expect(hookRef.current!.currentIndex).toBe(3); - }); - }); - - // 5 – setCommitMessage advances to branchName - describe('setCommitMessage', () => { - it('should set commitMessage and advance to branchName', () => { - act(() => { - hookRef.current!.selectBundle('b'); - }); - act(() => { - hookRef.current!.setInputMethod('inline'); - }); - act(() => { - hookRef.current!.setComponents({}, '{}'); - }); - act(() => { - hookRef.current!.setCommitMessage('update config'); - }); - - expect(hookRef.current!.config.commitMessage).toBe('update config'); - expect(hookRef.current!.step).toBe('branchName'); - expect(hookRef.current!.currentIndex).toBe(4); - }); - }); - - // 6 – setBranchName advances to confirm - describe('setBranchName', () => { - it('should set branchName and advance to confirm', () => { - act(() => { - hookRef.current!.selectBundle('b'); - }); - act(() => { - hookRef.current!.setInputMethod('inline'); - }); - act(() => { - hookRef.current!.setComponents({}, '{}'); - }); - act(() => { - hookRef.current!.setCommitMessage('msg'); - }); - act(() => { - hookRef.current!.setBranchName('feature/edit'); - }); - - expect(hookRef.current!.config.branchName).toBe('feature/edit'); - expect(hookRef.current!.step).toBe('confirm'); - expect(hookRef.current!.currentIndex).toBe(5); - }); - }); - - // 7 – goBack moves to previous step - describe('goBack', () => { - it('should move from inputMethod back to selectBundle', () => { - act(() => { - hookRef.current!.selectBundle('b'); - }); - - expect(hookRef.current!.step).toBe('inputMethod'); - - act(() => { - hookRef.current!.goBack(); - }); - - expect(hookRef.current!.step).toBe('selectBundle'); - expect(hookRef.current!.currentIndex).toBe(0); - }); - - it('should move from components back to inputMethod', () => { - act(() => { - hookRef.current!.selectBundle('b'); - }); - act(() => { - hookRef.current!.setInputMethod('inline'); - }); - - expect(hookRef.current!.step).toBe('components'); - - act(() => { - hookRef.current!.goBack(); - }); - - expect(hookRef.current!.step).toBe('inputMethod'); - expect(hookRef.current!.currentIndex).toBe(1); - }); - }); - - // 8 – goBack does nothing on first step - describe('goBack on first step', () => { - it('should remain on selectBundle when goBack is called at the start', () => { - act(() => { - hookRef.current!.goBack(); - }); - - expect(hookRef.current!.step).toBe('selectBundle'); - expect(hookRef.current!.currentIndex).toBe(0); - }); - }); - - // 9 – reset returns to initial state - describe('reset', () => { - it('should return to initial state after progressing through steps', () => { - act(() => { - hookRef.current!.selectBundle('b'); - }); - act(() => { - hookRef.current!.setInputMethod('file'); - }); - act(() => { - hookRef.current!.setComponents({ x: { v: '1' } }, '{"x":{"v":"1"}}'); - }); - - expect(hookRef.current!.step).toBe('commitMessage'); - expect(hookRef.current!.config.bundleName).toBe('b'); - - act(() => { - hookRef.current!.reset(); - }); - - expect(hookRef.current!.step).toBe('selectBundle'); - expect(hookRef.current!.currentIndex).toBe(0); - expect(hookRef.current!.config).toEqual(DEFAULT_CONFIG); - }); - }); - - // 11 – currentIndex tracks step position throughout the wizard - describe('currentIndex', () => { - it('should track step position as the wizard progresses', () => { - expect(hookRef.current!.currentIndex).toBe(0); - - act(() => { - hookRef.current!.selectBundle('b'); - }); - expect(hookRef.current!.currentIndex).toBe(1); - - act(() => { - hookRef.current!.setInputMethod('inline'); - }); - expect(hookRef.current!.currentIndex).toBe(2); - - act(() => { - hookRef.current!.setComponents({}, '{}'); - }); - expect(hookRef.current!.currentIndex).toBe(3); - - act(() => { - hookRef.current!.setCommitMessage('m'); - }); - expect(hookRef.current!.currentIndex).toBe(4); - - act(() => { - hookRef.current!.setBranchName('br'); - }); - expect(hookRef.current!.currentIndex).toBe(5); - }); - }); -}); - -// --------------------------------------------------------------------------- -// EDIT_STEP_LABELS – exported constant -// --------------------------------------------------------------------------- - -describe('EDIT_STEP_LABELS', () => { - it('should have a label for every step', () => { - for (const step of ALL_STEPS) { - expect(EDIT_STEP_LABELS[step]).toBeDefined(); - expect(typeof EDIT_STEP_LABELS[step]).toBe('string'); - } - }); - - it('should map steps to the expected labels', () => { - expect(EDIT_STEP_LABELS).toEqual({ - selectBundle: 'Bundle', - inputMethod: 'Input', - components: 'Components', - commitMessage: 'Message', - branchName: 'Branch', - confirm: 'Confirm', - }); - }); -}); diff --git a/src/cli/tui/screens/config-bundle/index.ts b/src/cli/tui/screens/config-bundle/index.ts index b50efbd10..831a3c94e 100644 --- a/src/cli/tui/screens/config-bundle/index.ts +++ b/src/cli/tui/screens/config-bundle/index.ts @@ -1,2 +1 @@ export { AddConfigBundleFlow } from './AddConfigBundleFlow'; -export { EditConfigBundleFlow } from './EditConfigBundleFlow'; diff --git a/src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts deleted file mode 100644 index 20c519407..000000000 --- a/src/cli/tui/screens/config-bundle/useEditConfigBundleWizard.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { ComponentConfigurationMap } from '../../../../schema'; -import type { ComponentInputMethod } from './types'; -import { useCallback, useState } from 'react'; - -export type EditConfigBundleStep = - | 'selectBundle' - | 'inputMethod' - | 'components' - | 'commitMessage' - | 'branchName' - | 'confirm'; - -const ALL_STEPS: EditConfigBundleStep[] = [ - 'selectBundle', - 'inputMethod', - 'components', - 'commitMessage', - 'branchName', - 'confirm', -]; - -export const EDIT_STEP_LABELS: Record = { - selectBundle: 'Bundle', - inputMethod: 'Input', - components: 'Components', - commitMessage: 'Message', - branchName: 'Branch', - confirm: 'Confirm', -}; - -export interface EditConfigBundleConfig { - bundleName: string; - inputMethod: ComponentInputMethod; - components: ComponentConfigurationMap; - componentsRaw: string; - commitMessage: string; - branchName: string; -} - -function getDefaultConfig(): EditConfigBundleConfig { - return { - bundleName: '', - inputMethod: 'inline', - components: {}, - componentsRaw: '', - commitMessage: '', - branchName: '', - }; -} - -export function useEditConfigBundleWizard() { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('selectBundle'); - - const currentIndex = ALL_STEPS.indexOf(step); - - const goBack = useCallback(() => { - const prevStep = ALL_STEPS[currentIndex - 1]; - if (prevStep) setStep(prevStep); - }, [currentIndex]); - - const nextStep = useCallback((currentStep: EditConfigBundleStep): EditConfigBundleStep | undefined => { - const idx = ALL_STEPS.indexOf(currentStep); - return ALL_STEPS[idx + 1]; - }, []); - - const selectBundle = useCallback( - (bundleName: string) => { - setConfig(c => ({ ...c, bundleName })); - const next = nextStep('selectBundle'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setInputMethod = useCallback( - (inputMethod: ComponentInputMethod) => { - setConfig(c => ({ ...c, inputMethod })); - const next = nextStep('inputMethod'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setComponents = useCallback( - (components: ComponentConfigurationMap, raw: string) => { - setConfig(c => ({ ...c, components, componentsRaw: raw })); - const next = nextStep('components'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setCommitMessage = useCallback( - (commitMessage: string) => { - setConfig(c => ({ ...c, commitMessage })); - const next = nextStep('commitMessage'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setBranchName = useCallback( - (branchName: string) => { - setConfig(c => ({ ...c, branchName })); - const next = nextStep('branchName'); - if (next) setStep(next); - }, - [nextStep] - ); - - const reset = useCallback(() => { - setConfig(getDefaultConfig()); - setStep('selectBundle'); - }, []); - - return { - config, - step, - steps: ALL_STEPS, - currentIndex, - goBack, - selectBundle, - setInputMethod, - setComponents, - setCommitMessage, - setBranchName, - reset, - }; -} diff --git a/src/cli/tui/screens/edit/EditFlow.tsx b/src/cli/tui/screens/edit/EditFlow.tsx deleted file mode 100644 index c9a279600..000000000 --- a/src/cli/tui/screens/edit/EditFlow.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { EditConfigBundleFlow } from '../config-bundle/EditConfigBundleFlow'; -import type { EditResourceType } from './EditScreen'; -import { EditScreen } from './EditScreen'; -import React, { useState } from 'react'; - -type FlowState = { name: 'select' } | { name: 'config-bundle' }; - -interface EditFlowProps { - isInteractive?: boolean; - onExit: () => void; - onBack: () => void; - onDev?: () => void; - onDeploy?: () => void; - initialResourceType?: EditResourceType; -} - -export function EditFlow({ - isInteractive = true, - onExit, - onBack, - onDev, - onDeploy, - initialResourceType, -}: EditFlowProps) { - const getInitialState = (): FlowState => { - if (!initialResourceType) return { name: 'select' }; - switch (initialResourceType) { - case 'config-bundle': - return { name: 'config-bundle' }; - default: - return { name: 'select' }; - } - }; - - const [flow, setFlow] = useState(getInitialState); - - const handleSelectResource = (resourceType: EditResourceType) => { - switch (resourceType) { - case 'config-bundle': - setFlow({ name: 'config-bundle' }); - break; - } - }; - - if (flow.name === 'select') { - return ; - } - - if (flow.name === 'config-bundle') { - return ( - setFlow({ name: 'select' })} - onDev={onDev} - onDeploy={onDeploy} - /> - ); - } - - return null; -} diff --git a/src/cli/tui/screens/edit/EditScreen.tsx b/src/cli/tui/screens/edit/EditScreen.tsx deleted file mode 100644 index 6683da0f9..000000000 --- a/src/cli/tui/screens/edit/EditScreen.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { SelectableItem } from '../../components'; -import { SelectScreen } from '../../components'; - -const EDIT_RESOURCES = [ - { id: 'config-bundle', title: 'Configuration Bundle', description: 'Edit versioned component configurations' }, -] as const; - -const EDIT_RESOURCE_ITEMS: SelectableItem[] = EDIT_RESOURCES.map(r => ({ - ...r, - disabled: false, - description: r.description, -})); - -export type EditResourceType = (typeof EDIT_RESOURCES)[number]['id']; - -interface EditScreenProps { - onSelect: (resourceType: EditResourceType) => void; - onExit: () => void; -} - -export function EditScreen({ onSelect, onExit }: EditScreenProps) { - return ( - onSelect(item.id as EditResourceType)} - onExit={onExit} - /> - ); -} diff --git a/src/cli/tui/screens/edit/index.ts b/src/cli/tui/screens/edit/index.ts deleted file mode 100644 index 2e8e4aa70..000000000 --- a/src/cli/tui/screens/edit/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { EditFlow } from './EditFlow'; -export { EditScreen, type EditResourceType } from './EditScreen'; From 64bcd69454c15c7d528ad91e2664bb5da6282104 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:53:57 -0400 Subject: [PATCH 14/64] feat: add Recommendations API, TUI wizard, and CLI commands (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../agentcore-recommendation.test.ts | 291 ++++++++ src/cli/aws/agentcore-recommendation.ts | 371 ++++++++++ src/cli/aws/index.ts | 18 + src/cli/cli.ts | 2 + src/cli/commands/recommendations/command.tsx | 63 ++ src/cli/commands/recommendations/index.ts | 1 + src/cli/commands/run/command.tsx | 170 +++++ .../__tests__/fetch-session-spans.test.ts | 224 ++++++ .../__tests__/recommendation-storage.test.ts | 133 ++++ .../__tests__/run-recommendation.test.ts | 697 ++++++++++++++++++ .../operations/recommendation/constants.ts | 11 + .../recommendation/fetch-session-spans.ts | 158 ++++ src/cli/operations/recommendation/index.ts | 16 + .../recommendation/recommendation-storage.ts | 84 +++ .../recommendation/run-recommendation.ts | 453 ++++++++++++ src/cli/operations/recommendation/types.ts | 64 ++ src/cli/tui/App.tsx | 28 + src/cli/tui/components/MultiSelectList.tsx | 28 +- src/cli/tui/components/SelectList.tsx | 26 +- src/cli/tui/components/WizardSelect.tsx | 22 +- src/cli/tui/copy.ts | 2 + .../recommendation/RecommendationFlow.tsx | 428 +++++++++++ .../RecommendationHistoryScreen.tsx | 256 +++++++ .../recommendation/RecommendationScreen.tsx | 450 +++++++++++ .../RecommendationsHubScreen.tsx | 48 ++ src/cli/tui/screens/recommendation/index.ts | 3 + src/cli/tui/screens/recommendation/types.ts | 56 ++ .../recommendation/useRecommendationWizard.ts | 187 +++++ 28 files changed, 4283 insertions(+), 7 deletions(-) create mode 100644 src/cli/aws/__tests__/agentcore-recommendation.test.ts create mode 100644 src/cli/aws/agentcore-recommendation.ts create mode 100644 src/cli/commands/recommendations/command.tsx create mode 100644 src/cli/commands/recommendations/index.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/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/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 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..340c10f37 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-recommendation.test.ts @@ -0,0 +1,291 @@ +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: [] }, + }, + }, + }); + + 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: [] }, + }, + }, + }); + + 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/agentcore-recommendation.ts b/src/cli/aws/agentcore-recommendation.ts new file mode 100644 index 000000000..82cc1e8a9 --- /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 { 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 — which evaluator(s) to use as objective signal. */ +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 SystemPromptRecommendationResult { + recommendedSystemPrompt?: string; + explanation?: string; + errorCode?: string; + errorMessage?: string; +} + +export interface ToolDescriptionRecommendationToolResult { + toolName: string; + recommendedToolDescription: string; + explanation: string; +} + +export interface ToolDescriptionRecommendationResult { + tools?: ToolDescriptionRecommendationToolResult[]; + configurationBundle?: { + bundleArn: string; + versionId: string; + }; + 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}.amazonaws.com`; +} + +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}\n[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/index.ts b/src/cli/aws/index.ts index 21c213b65..57077e97a 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -41,3 +41,21 @@ 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'; diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 0d614950b..09f12b734 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -11,6 +11,7 @@ import { registerInvoke } from './commands/invoke'; import { registerLogs } from './commands/logs'; import { registerPackage } from './commands/package'; import { registerPause } from './commands/pause'; +import { registerRecommendations } from './commands/recommendations'; import { registerRemove } from './commands/remove'; import { registerResume } from './commands/resume'; import { registerRun } from './commands/run'; @@ -145,6 +146,7 @@ export function registerCommands(program: Command) { registerLogs(program); registerPackage(program); registerPause(program); + registerRecommendations(program); const removeCmd = registerRemove(program); registerResume(program); registerRun(program); 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/run/command.tsx b/src/cli/commands/run/command.tsx index fffe23a5a..745ae8eb2 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -1,12 +1,19 @@ +import type { RecommendationType } from '../../aws/agentcore-recommendation'; import { getErrorMessage } from '../../errors'; import { handleRunEval } from '../../operations/eval'; import type { RunEvalOptions } from '../../operations/eval'; +import { runRecommendationCommand, saveRecommendationRun } 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'; +const RECOMMENDATION_TYPE_MAP: Record = { + 'system-prompt': 'SYSTEM_PROMPT_RECOMMENDATION', + 'tool-description': 'TOOL_DESCRIPTION_RECOMMENDATION', +}; + function formatRunOutput(result: Awaited>): void { if (!result.run) return; @@ -148,4 +155,167 @@ export const registerRun = (program: Command) => { } } ); + + runCmd + .command('recommendation') + .description('Run an optimization recommendation for system prompt or tool descriptions') + .option('-t, --type ', 'What to optimize: system-prompt or tool-description') + .option('-a, --agent ', 'Agent name from project') + .option('-e, --evaluator ', 'Evaluator name(s) or Builtin.* ID(s) (repeatable)') + .option('--prompt-file ', 'Load system prompt from file') + .option('--inline ', 'Provide content inline') + .option('--bundle-name ', 'Config bundle name') + .option('--bundle-version ', 'Config bundle version') + .option('--tools ', 'Comma-separated toolName:description pairs (for tool-description type)') + .option('--spans-file ', 'JSON file with session spans (inline traces instead of CloudWatch)') + .option('--lookback ', 'Lookback window in days', '7') + .option('-s, --session-id ', 'Specific session IDs for traces') + .option('-r, --run ', 'Run name prefix') + .option('--region ', 'AWS region') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + type?: string; + agent?: string; + evaluator?: string[]; + promptFile?: string; + inline?: string; + bundleName?: string; + bundleVersion?: 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.agent; + const evaluators = cliOptions.evaluator; + + if (!agent) { + const error = '--agent is required'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + + if (!evaluators || evaluators.length === 0) { + const error = '--evaluator is required (at least one)'; + 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); + + const result = await runRecommendationCommand({ + type: recType, + agent, + evaluators, + promptFile: cliOptions.promptFile, + inlineContent: cliOptions.inline, + bundleName: cliOptions.bundleName, + bundleVersion: cliOptions.bundleVersion, + tools: cliOptions.tools ? cliOptions.tools.split(',').map(t => t.trim()) : undefined, + lookbackDays: parseInt(cliOptions.lookback, 10), + sessionIds: cliOptions.sessionId, + spansFile: cliOptions.spansFile, + recommendationName: cliOptions.run, + region: cliOptions.region, + inputSource, + traceSource, + }); + + if (!result.success) { + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + render({result.error}); + } + process.exit(1); + } + + // Save results locally + try { + if (result.recommendationId) { + saveRecommendationRun(result.recommendationId, result, recType, agent, evaluators); + } + } 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.explanation) { + console.log(`\nWhat changed: ${sysResult.explanation}`); + } + 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(`Explanation: ${tool.explanation}`); + console.log(`Recommended: ${tool.recommendedToolDescription}`); + } + } + } + + 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); + } + } + ); }; 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..722be6535 --- /dev/null +++ b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts @@ -0,0 +1,133 @@ +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.', + explanation: 'Made prompt more specific.', + }, + }, + ...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?.explanation).toBe('Made prompt more specific.'); + }); + }); + + 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..c49f5b857 --- /dev/null +++ b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts @@ -0,0 +1,697 @@ +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: { + agents: { + 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; + }, +})); + +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('includes sessionIds in cloudwatch config when provided', async () => { + 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: 'cloudwatch', + sessionIds: ['sess-1', 'sess-2'], + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const cwConfig = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs; + expect(cwConfig.sessionIds).toEqual(['sess-1', 'sess-2']); + }); + + 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'); + expect(result.error).toContain('start-req-id'); + expect(result.error).toContain('poll-req-id'); + }); + + 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 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..964b183c8 --- /dev/null +++ b/src/cli/operations/recommendation/index.ts @@ -0,0 +1,16 @@ +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..e60846574 --- /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'; + +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..d508d229a --- /dev/null +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -0,0 +1,453 @@ +/** + * 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 { 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] = await Promise.all([configIO.readProjectSpec(), configIO.readDeployedState()]); + + const { region: detectedRegion } = await detectRegion(); + const region = options.region ?? 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 IDs/ARNs + const evaluatorIds: string[] = []; + for (const evaluator of options.evaluators) { + const evaluatorId = resolveEvaluatorId(deployedState, evaluator); + 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); + } + logger?.log(`Evaluators: ${evaluatorIds.join(', ')}`); + 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; + } + + // 5. Extract account ID from agent runtime ARN + const accountId = extractAccountIdFromArn(agentState.runtimeArn); + + // 6. Build recommendationConfig based on type + const recommendationConfig = await buildRecommendationConfig({ + type: options.type, + inlineContent, + bundleName: options.bundleName, + bundleVersion: options.bundleVersion, + 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})`); + + // 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, + 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); + const requestIds = [ + startResult.requestId ? `Start: ${startResult.requestId}` : '', + pollResult.requestId ? `Poll: ${pollResult.requestId}` : '', + ] + .filter(Boolean) + .join(', '); + const requestIdSuffix = requestIds ? `\n\nRequest IDs (share with API team): ${requestIds}` : ''; + + return { + success: false, + error: failureDetails + ? `Recommendation failed: ${failureDetails}${requestIdSuffix}` + : `Recommendation finished with status: ${currentStatus}${requestIdSuffix}`, + 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): 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 `arn:aws: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; + bundleName?: string; + bundleVersion?: 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.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && + opts.traceSource === 'sessions' && + opts.sessionIds && + opts.sessionIds.length > 0 + ) { + // Tool-desc with session IDs — auto-fetch from both log groups and use inline sessionSpans. + // The server-side ToolDescRecWorkflowLambda does NOT support cloudwatchLogs, only sessionSpans. + opts.onProgress?.('fetching-spans', 'Fetching session spans from CloudWatch...'); + opts.logger?.log('Auto-fetching spans for tool-desc recommendation (cloudwatchLogs not supported server-side)'); + + 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 { + // System prompt path (or tool-desc with cloudwatch fallback) — use cloudwatchLogs + const runtimeLogGroupArn = `arn:aws:logs:${opts.region}:${opts.accountId}:log-group:/aws/bedrock-agentcore/runtimes/${opts.runtimeId}-DEFAULT`; + const spansLogGroupArn = `arn:aws: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(), + ...(opts.sessionIds && opts.sessionIds.length > 0 ? { sessionIds: opts.sessionIds } : {}), + }, + }; + } + + const evaluationConfig = { + evaluators: opts.evaluatorIds.map(id => ({ evaluatorArn: id })), + }; + + if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION') { + return { + systemPromptRecommendationConfig: { + systemPrompt: + opts.inputSource === 'config-bundle' && opts.bundleName + ? { + configurationBundle: { + bundleArn: opts.bundleName, + versionId: opts.bundleVersion, + }, + } + : { text: opts.inlineContent ?? '' }, + agentTraces, + evaluationConfig, + }, + }; + } + + // TOOL_DESCRIPTION_RECOMMENDATION — 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; +} + +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..c38577e6a --- /dev/null +++ b/src/cli/operations/recommendation/types.ts @@ -0,0 +1,64 @@ +/** + * 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 names, Builtin.* IDs, or ARNs */ + 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; + /** 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; +} + +export interface RunRecommendationCommandResult { + success: boolean; + error?: string; + recommendationId?: string; + status?: string; + /** The recommendation result from the API (populated on COMPLETED) */ + result?: RecommendationResult; + startedAt?: string; + completedAt?: string; + /** Path to the execution log file */ + logFilePath?: string; +} diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index b7fc8d833..6c802b2c1 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -15,6 +15,7 @@ import { HelpScreen, HomeScreen } from './screens/home'; 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 { StatusScreen } from './screens/status/StatusScreen'; @@ -39,6 +40,9 @@ type Route = | { name: 'remove' } | { name: 'run' } | { name: 'run-eval'; from?: 'run' | 'evals' } + | { name: 'recommendations-hub' } + | { name: 'recommend'; from?: 'recommendations-hub' } + | { name: 'recommendation-history' } | { name: 'evals' } | { name: 'eval-runs' } | { name: 'online-evals' } @@ -110,6 +114,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') { @@ -237,6 +243,28 @@ function AppContent() { ); } + if (route.name === 'recommendations-hub') { + return ( + { + if (view === 'run-recommendation') setRoute({ name: 'recommend', from: 'recommendations-hub' }); + if (view === 'recommendation-history') setRoute({ name: 'recommendation-history' }); + if (view === 'list-recommendations') setRoute({ name: 'help' }); // TODO: wire to list recommendations TUI + }} + 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' })} />; } 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/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/copy.ts b/src/cli/tui/copy.ts index 78a8239b8..690de620e 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -45,6 +45,8 @@ 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.', + recommend: 'Run optimization recommendations for system prompts and tool descriptions.', + recommendations: 'Manage optimization recommendations (history).', run: 'Run on-demand evaluation. Supports --agent-arn for agents outside the project.', import: 'Import resources from a Bedrock AgentCore Starter Toolkit project.', update: 'Check for and install CLI updates', diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx new file mode 100644 index 000000000..928ab4428 --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -0,0 +1,428 @@ +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 { 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, 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[] } + | { name: 'running'; config: RecommendationWizardConfig; steps: Step[]; elapsed: number } + | { name: 'results'; result: RunRecommendationCommandResult; config: RecommendationWizardConfig; filePath?: string } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: 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, + })); + + setFlow({ name: 'wizard', agents, evaluators }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); + + const handleRunComplete = useCallback((config: RecommendationWizardConfig) => { + const isToolDescWithSessions = + config.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && config.traceSource === 'sessions'; + + const initialSteps: Step[] = [ + ...(isToolDescWithSessions + ? [{ 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 (isToolDescWithSessions) { + 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, + tools: config.tools + ? config.tools + .split(',') + .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.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && 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 }; + }); + }, + }); + + 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' }); + return; + } + + // Mark polling success, saving running + const hasFetchStep = config.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && 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 actions = [ + { id: 'another', title: 'Run another recommendation' }, + { id: 'back', title: 'Back' }, + ]; + + const nav = useListNavigation({ + items: actions, + onSelect: item => { + 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.explanation && ( + + What changed: {sysResult.explanation} + + )} + {sysResult.recommendedSystemPrompt && ( + + + Recommended System Prompt: + + + {sysResult.recommendedSystemPrompt} + + + )} + + )} + + {toolResult?.tools && toolResult.tools.length > 0 && ( + + + Recommended Tool Descriptions: + + {toolResult.tools.map(tool => ( + + {tool.toolName} + Explanation: {tool.explanation} + {tool.recommendedToolDescription} + + ))} + + )} + + {!sysResult && !toolResult && ( + + No recommendation results returned. + + )} + + {filePath && ( + + Results saved to: {filePath} + + )} + + + {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; +} diff --git a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx new file mode 100644 index 000000000..99ec53001 --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx @@ -0,0 +1,256 @@ +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.explanation && ( + + What changed: {sysResult.explanation} + + )} + {sysResult.recommendedSystemPrompt && ( + + + Recommended System Prompt: + + + {sysResult.recommendedSystemPrompt} + + + )} + + )} + + {toolResult?.tools && toolResult.tools.length > 0 && ( + + + Recommended Tool Descriptions: + + {toolResult.tools.map(tool => ( + + {tool.toolName} + Explanation: {tool.explanation} + {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..00dbe741a --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -0,0 +1,450 @@ +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, 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[]; + onComplete: (config: RecommendationWizardConfig) => void; + onExit: () => void; +} + +export function RecommendationScreen({ agents, evaluators, 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 inputSourceItems: SelectableItem[] = useMemo( + () => [ + { id: 'inline', title: 'Enter inline', description: 'Type or paste content directly' }, + { id: 'file', title: 'Load from file', description: 'Read content from a file path' }, + ], + [] + ); + + const isToolDesc = wizard.config.type === 'TOOL_DESCRIPTION_RECOMMENDATION'; + + const traceSourceItems: SelectableItem[] = useMemo( + () => + isToolDesc + ? [ + { + id: 'sessions', + title: 'Session IDs', + description: 'Select sessions — spans are auto-fetched from CloudWatch', + }, + ] + : [ + { id: 'cloudwatch', title: 'CloudWatch Logs', description: 'Discover traces from agent runtime logs' }, + { id: 'sessions', title: 'Session IDs', description: 'Provide specific session IDs manually' }, + ], + [isToolDesc] + ); + + // ── 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 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 = useMultiSelectNavigation({ + items: evaluatorItems, + getId: item => item.id, + onConfirm: ids => wizard.setEvaluators(ids), + onExit: () => wizard.goBack(), + isActive: isEvaluatorStep, + requireSelection: true, + }); + + const inputSourceNav = useListNavigation({ + items: inputSourceItems, + onSelect: item => wizard.setInputSource(item.id as 'inline' | 'file'), + onExit: () => wizard.goBack(), + isActive: isInputSourceStep, + }); + + 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.MULTI_SELECT + : isSessionsStep + ? sessionPhase === 'loading' + ? '' + : sessionPhase === 'error' + ? HELP_TEXT.CONFIRM_CANCEL + : 'Space toggle · Enter confirm · Esc back' + : isTypeStep || isAgentStep || isInputSourceStep || isTraceSourceStep + ? 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 }, + { + label: 'Evaluator(s)', + 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}` : 'Inline' }, + { + label: 'Traces', + value: + wizard.config.traceSource === 'sessions' + ? `${wizard.config.sessionIds.length} session${wizard.config.sessionIds.length !== 1 ? 's' : ''} selected${isToolDesc ? ' (auto-fetch)' : ''}` + : `CloudWatch (${wizard.config.days}d)`, + }, + ]; + + if (!isSystemPrompt) { + 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" + /> + )} + + {isToolsStep && ( + + Enter tool names and descriptions as comma-separated toolName:description pairs. + wizard.goBack()} + expandable + /> + + )} + + {isTraceSourceStep && ( + + {isToolDesc && ( + + Note: CloudWatch trace source is not supported for tool description recommendations. Spans will be + auto-fetched from CloudWatch for the selected sessions. + + )} + + + )} + + {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..ed34f69f3 --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx @@ -0,0 +1,48 @@ +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' | 'list-recommendations'; + +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)', + }, + { + id: 'list-recommendations', + title: 'List Recommendations', + description: 'List recommendations from the API', + }, + ], + [] + ); + + 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..cdd96dfa7 --- /dev/null +++ b/src/cli/tui/screens/recommendation/types.ts @@ -0,0 +1,56 @@ +import type { + RecommendationInputSourceKind, + RecommendationType, + TraceSourceKind, +} from '../../../operations/recommendation'; + +export type RecommendationStep = + | 'type' + | 'agent' + | 'evaluator' + | 'inputSource' + | 'content' + | '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[]; +} + +export const RECOMMENDATION_STEP_LABELS: Record = { + type: 'Type', + agent: 'Agent', + evaluator: 'Evaluator', + inputSource: 'Source', + content: 'Content', + 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; +} diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts new file mode 100644 index 000000000..012753408 --- /dev/null +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -0,0 +1,187 @@ +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', 'inputSource']; + + // Content step for inline/file; skip for config-bundle + if (inputSource === 'inline' || inputSource === 'file') { + steps.push('content'); + } + + // Tools step only for tool description recommendations + if (type === 'TOOL_DESCRIPTION_RECOMMENDATION') { + steps.push('tools'); + } + + steps.push('traceSource'); + + // For tool-desc, traceSource is always 'sessions' (cloudwatch not supported server-side). + // The effective traceSource for step logic: + const effectiveTraceSource = type === 'TOOL_DESCRIPTION_RECOMMENDATION' ? 'sessions' : traceSource; + + if (effectiveTraceSource === '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: [], + }; +} + +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 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, + setTools, + setTraceSource, + setDays, + setSessions, + reset, + }; +} From e65507a360d8964ffea1d7b96e4c3a85408cf0e1 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:58:51 -0400 Subject: [PATCH 15/64] =?UTF-8?q?feat:=20batch=20evaluation=20=E2=80=94=20?= =?UTF-8?q?stateless=20eval=20API,=20TUI=20wizard,=20local=20storage=20(#2?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- integ-tests/create-frameworks.test.ts | 1 - integ-tests/create-memory.test.ts | 1 - integ-tests/create-protocols.test.ts | 1 - src/cli/aws/agentcore-batch-evaluation.ts | 258 +++++++++ src/cli/aws/agentcore.ts | 9 +- src/cli/cli.ts | 2 + src/cli/commands/index.ts | 1 + src/cli/commands/run/command.tsx | 112 ++++ src/cli/commands/stop/command.tsx | 46 ++ src/cli/commands/stop/index.ts | 1 + src/cli/commands/update/command.tsx | 51 +- src/cli/operations/eval/batch-eval-storage.ts | 72 +++ .../operations/eval/run-batch-evaluation.ts | 286 ++++++++++ src/cli/operations/eval/run-eval.ts | 2 +- src/cli/tui/App.tsx | 9 +- src/cli/tui/copy.ts | 1 + src/cli/tui/screens/home/HelpScreen.tsx | 4 +- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 496 ++++++++++++++++++ src/cli/tui/screens/run-eval/RunScreen.tsx | 13 +- src/cli/tui/screens/run-eval/index.ts | 1 + 20 files changed, 1353 insertions(+), 14 deletions(-) create mode 100644 src/cli/aws/agentcore-batch-evaluation.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/eval/batch-eval-storage.ts create mode 100644 src/cli/operations/eval/run-batch-evaluation.ts create mode 100644 src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx 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-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/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts new file mode 100644 index 000000000..f9b7a1dc8 --- /dev/null +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -0,0 +1,258 @@ +/** + * 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/{id} → GetBatchEvaluation + * GET /evaluations/batch-evaluate → ListBatchEvaluations + * POST /evaluations/batch-evaluate/{id}/stop → StopBatchEvaluation + * + * Uses direct HTTP requests with SigV4 signing (service: bedrock-agentcore). + */ +import { getCredentialProvider } from './account'; +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 CloudWatchSource { + serviceNames: string[]; + logGroupNames: string[]; +} + +export interface BatchEvaluationConfig { + evaluators: { evaluatorId: string }[]; +} + +export interface StartBatchEvaluationOptions { + region: string; + name: string; + evaluationConfig: BatchEvaluationConfig; + sessionSource: { + cloudWatchSource: CloudWatchSource; + }; + executionRoleArn?: string; + clientToken?: string; +} + +export interface StartBatchEvaluationResult { + batchEvaluateId: string; + name: string; + status: string; + createdAt?: string; +} + +export interface GetBatchEvaluationOptions { + region: string; + batchEvaluateId: string; +} + +export interface GetBatchEvaluationResult { + batchEvaluateId: string; + name: string; + status: string; + createdAt?: string; + updatedAt?: string; + evaluationConfig?: BatchEvaluationConfig; + sessionSource?: { + cloudWatchSource?: CloudWatchSource; + }; + outputDataConfig?: { + cloudWatchDestination?: { + logGroupName: string; + logStreamName: string; + }; + }; + results?: BatchEvaluationResultEntry[]; + errorDetails?: string[]; + statusReasons?: 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 { + batchEvaluateId: string; + name: string; + status: string; + createdAt?: string; + updatedAt?: string; +} + +export interface ListBatchEvaluationsResult { + batchEvaluations: BatchEvaluationSummary[]; + nextToken?: string; +} + +export interface StopBatchEvaluationOptions { + region: string; + batchEvaluateId: string; +} + +export interface StopBatchEvaluationResult { + batchEvaluateId: 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}.amazonaws.com`; +} + +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 = { + name: options.name, + evaluationConfig: options.evaluationConfig, + sessionSource: options.sessionSource, + }; + if (options.executionRoleArn) { + body.executionRoleArn = options.executionRoleArn; + } + if (options.clientToken) { + body.clientToken = options.clientToken; + } + + const { data } = await signedRequest({ + region: options.region, + method: 'POST', + path: '/evaluations/batch-evaluate', + body: JSON.stringify(body), + }); + + return data as StartBatchEvaluationResult; +} + +/** + * 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.batchEvaluateId}`, + }); + + return data as GetBatchEvaluationResult; +} + +/** + * 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.batchEvaluateId}/stop`, + }); + + return data as StopBatchEvaluationResult; +} + +/** + * Generate a client token for idempotency. + */ +export function generateClientToken(): string { + return crypto.randomUUID(); +} diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 204610025..6f98a8fae 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -3,13 +3,20 @@ import { getCredentialProvider } from './account'; import { BedrockAgentCoreClient, EvaluateCommand, - type EvaluationReferenceInput, InvokeAgentRuntimeCommand, StopRuntimeSessionCommand, } from '@aws-sdk/client-bedrock-agentcore'; 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. */ diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 09f12b734..0a17385c4 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -16,6 +16,7 @@ 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 { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; @@ -151,6 +152,7 @@ export function registerCommands(program: Command) { registerResume(program); registerRun(program); registerStatus(program); + registerStop(program); registerTraces(program); registerUpdate(program); registerValidate(program); diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index c8c1bd68b..a7eda0787 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -11,6 +11,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/run/command.tsx b/src/cli/commands/run/command.tsx index 745ae8eb2..fb2213921 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -2,6 +2,12 @@ import type { RecommendationType } from '../../aws/agentcore-recommendation'; 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 type { + BatchEvaluationResult, + RunBatchEvaluationCommandResult, +} from '../../operations/eval/run-batch-evaluation'; import { runRecommendationCommand, saveRecommendationRun } from '../../operations/recommendation'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; @@ -156,6 +162,72 @@ export const registerRun = (program: Command) => { } ); + runCmd + .command('batch-evaluation') + .description('Run a batch evaluation against agent sessions') + .requiredOption('-a, --agent ', 'Agent name from project config') + .requiredOption('-e, --evaluator ', 'Evaluator ID(s) (Builtin.* or custom)') + .option('-n, --name ', 'Name for the batch evaluation (auto-generated if omitted)') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--execution-role ', 'IAM execution role ARN (temporary — will be removed)') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + agent: string; + evaluator: string[]; + name?: string; + region?: string; + executionRole?: string; + json?: boolean; + }) => { + requireProject(); + + try { + const result = await runBatchEvaluationCommand({ + agent: cliOptions.agent, + evaluators: cliOptions.evaluator, + name: cliOptions.name, + region: cliOptions.region, + executionRoleArn: cliOptions.executionRole, + 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}); + } + + 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('Run an optimization recommendation for system prompt or tool descriptions') @@ -319,3 +391,43 @@ export const registerRun = (program: Command) => { } ); }; + +function formatBatchEvalOutput(result: RunBatchEvaluationCommandResult): void { + console.log(`\nBatch Evaluation: ${result.name ?? result.batchEvaluateId}`); + console.log(`ID: ${result.batchEvaluateId}`); + console.log(`Status: ${result.status}`); + console.log(`Results: ${result.results.length}\n`); + + if (result.results.length === 0) { + console.log(' No evaluation results found.'); + return; + } + + // Group by evaluator + 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, evalResults] of byEvaluator) { + 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; + const errorSuffix = errors > 0 ? ` (${errors} errors)` : ''; + + console.log(` ${evalId}: ${avg.toFixed(2)} avg${errorSuffix}`); + + for (const r of evalResults) { + 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(''); +} diff --git a/src/cli/commands/stop/command.tsx b/src/cli/commands/stop/command.tsx new file mode 100644 index 000000000..9a440e52e --- /dev/null +++ b/src/cli/commands/stop/command.tsx @@ -0,0 +1,46 @@ +import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; +import { detectRegion } from '../../aws/region'; +import { getErrorMessage } from '../../errors'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +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('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: detectedRegion } = await detectRegion(); + const region = cliOptions.region ?? detectedRegion; + + const result = await stopBatchEvaluation({ + region, + batchEvaluateId: cliOptions.id, + }); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`\nBatch evaluation stopped successfully`); + console.log(`ID: ${result.batchEvaluateId}`); + 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..3f55d16c9 --- /dev/null +++ b/src/cli/commands/stop/index.ts @@ -0,0 +1 @@ +export { registerStop } from './command'; 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/operations/eval/batch-eval-storage.ts b/src/cli/operations/eval/batch-eval-storage.ts new file mode 100644 index 000000000..fdb0f02ff --- /dev/null +++ b/src/cli/operations/eval/batch-eval-storage.ts @@ -0,0 +1,72 @@ +import { findConfigRoot } from '../../../lib'; +import type { BatchEvaluationResult, RunBatchEvaluationCommandResult } from './run-batch-evaluation'; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const BATCH_EVAL_RESULTS_DIR = 'batch-eval-results'; + +export interface BatchEvalRunRecord { + name: string; + batchEvaluateId: string; + status: string; + startedAt?: string; + completedAt?: string; + evaluators: string[]; + results: BatchEvaluationResult[]; +} + +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.batchEvaluateId ?? 'unknown'; + const filePath = join(dir, `${id}.json`); + + const record: BatchEvalRunRecord = { + name: result.name ?? 'unknown', + batchEvaluateId: id, + status: result.status ?? 'unknown', + startedAt: result.startedAt, + completedAt: result.completedAt, + evaluators: result.results.map(r => r.evaluatorId), + results: result.results, + }; + + writeFileSync(filePath, JSON.stringify(record, null, 2)); + return filePath; +} + +export function loadBatchEvalRun(batchEvaluateId: string): BatchEvalRunRecord { + const dir = getResultsDir(); + const jsonName = batchEvaluateId.endsWith('.json') ? batchEvaluateId : `${batchEvaluateId}.json`; + const filePath = join(dir, jsonName); + + if (!existsSync(filePath)) { + throw new Error(`Batch evaluation run "${batchEvaluateId}" 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..d09e9c97b --- /dev/null +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -0,0 +1,286 @@ +/** + * Orchestrates running a BatchEvaluation: + * 1. Resolve agent from deployed state (for serviceNames / logGroupNames) + * 2. Build evaluationConfig + sessionSource + * 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 { GetBatchEvaluationResult } 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; + /** Explicit execution role ARN (falls back to agent's deployed role) */ + executionRoleArn?: string; + /** Poll interval in ms */ + pollIntervalMs?: number; + /** Progress callback */ + onProgress?: (status: string, message: string) => void; +} + +export interface BatchEvaluationResult { + evaluatorId: string; + score?: number; + label?: string; + explanation?: string; + error?: string; +} + +export interface RunBatchEvaluationCommandResult { + success: boolean; + error?: string; + batchEvaluateId?: string; + name?: string; + status?: string; + results: BatchEvaluationResult[]; + startedAt?: string; + completedAt?: string; + logFilePath?: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_POLL_INTERVAL_MS = 10_000; +const TERMINAL_STATUSES = new Set(['COMPLETED', '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] = await Promise.all([configIO.readProjectSpec(), configIO.readDeployedState()]); + + const { region: detectedRegion } = await detectRegion(); + const region = options.region ?? 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; + const roleArn = agentState.roleArn; + // 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'); + + // 3. Start the batch evaluation + logger?.startStep('Start batch evaluation'); + const evalName = options.name ?? `${projectSpec.name}_${agent}_${Date.now()}`; + + onProgress?.('starting', `Starting batch evaluation "${evalName}"...`); + + const startPayload = { + region, + name: evalName, + evaluationConfig: { + evaluators: evaluators.map(id => ({ evaluatorId: id })), + }, + sessionSource: { + cloudWatchSource: { + serviceNames: [serviceName], + logGroupNames: [runtimeLogGroup], + }, + }, + executionRoleArn: options.executionRoleArn ?? roleArn, + 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.batchEvaluateId})`); + + // 4. Poll for completion + logger?.startStep('Poll for completion'); + let current: GetBatchEvaluationResult = { + batchEvaluateId: startResult.batchEvaluateId, + name: startResult.name, + status: startResult.status, + }; + + while (!TERMINAL_STATUSES.has(current.status)) { + await sleep(pollIntervalMs); + + current = await getBatchEvaluation({ + region, + batchEvaluateId: startResult.batchEvaluateId, + }); + + onProgress?.('polling', `Status: ${current.status}`); + logger?.log(`Poll status: ${current.status}`); + } + + if (current.status !== 'COMPLETED') { + const reasons = current.statusReasons?.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, + batchEvaluateId: startResult.batchEvaluateId, + 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.outputDataConfig?.cloudWatchDestination; + 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'); + } + } + + // Fall back to inline results if CW fetch returned nothing + if (results.length === 0 && current.results?.length) { + results = current.results.map(r => ({ + evaluatorId: r.evaluatorId, + score: r.score, + label: r.label, + explanation: r.explanation, + error: r.error, + })); + } + logger?.endStep('success'); + + logger?.log(`Results: ${JSON.stringify(results, null, 2)}`); + logger?.finalize(true); + + return { + success: true, + batchEvaluateId: startResult.batchEvaluateId, + name: evalName, + status: current.status, + results, + 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/tui/App.tsx b/src/cli/tui/App.tsx index 6c802b2c1..41b1377d1 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -17,7 +17,7 @@ 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 { RunBatchEvalFlow, RunEvalFlow, RunScreen } from './screens/run-eval'; import { StatusScreen } from './screens/status/StatusScreen'; import { UpdateScreen } from './screens/update'; import { ValidateScreen } from './screens/validate'; @@ -40,6 +40,7 @@ type Route = | { name: 'remove' } | { name: 'run' } | { name: 'run-eval'; from?: 'run' | 'evals' } + | { name: 'run-batch-eval'; from?: 'run' } | { name: 'recommendations-hub' } | { name: 'recommend'; from?: 'recommendations-hub' } | { name: 'recommendation-history' } @@ -215,6 +216,7 @@ function AppContent() { return ( setRoute({ name: 'run-eval', from: 'run' })} + onRunBatchEval={() => setRoute({ name: 'run-batch-eval', from: 'run' })} onExit={() => setRoute({ name: 'help' })} /> ); @@ -243,6 +245,11 @@ function AppContent() { ); } + if (route.name === 'run-batch-eval') { + const backRoute = route.from ?? 'run'; + return setRoute({ name: backRoute } as Route)} />; + } + if (route.name === 'recommendations-hub') { return ( { 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/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx new file mode 100644 index 000000000..1d3d2c059 --- /dev/null +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -0,0 +1,496 @@ +import { validateAwsCredentials } from '../../../aws/account'; +import { listEvaluators } from '../../../aws/agentcore-control'; +import { detectRegion } from '../../../aws/region'; +import { getErrorMessage } from '../../../errors'; +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 } from '../../../operations/resolve-agent'; +import { + ConfirmReview, + ErrorPrompt, + GradientText, + Panel, + Screen, + StepIndicator, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import type { EvaluatorItem } from '../online-eval/types'; +import type { AgentItem } from './types'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +type BatchEvalStep = 'agent' | 'evaluators' | 'name' | 'confirm'; + +interface BatchEvalConfig { + agent: string; + evaluators: string[]; + evaluatorNames: string[]; + name: string; +} + +const STEP_LABELS: Record = { + agent: 'Agent', + evaluators: 'Evaluators', + name: 'Name', + confirm: 'Confirm', +}; + +type FlowState = + | { name: 'loading' } + | { name: 'wizard'; agents: AgentItem[]; evaluators: EvaluatorItem[] } + | { name: 'running'; config: BatchEvalConfig; progress: string } + | { name: 'results'; result: RunBatchEvaluationCommandResult } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: string }; + +// ============================================================================ +// Flow Component +// ============================================================================ + +interface RunBatchEvalFlowProps { + onExit: () => void; +} + +export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { + 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 { region } = await detectRegion(); + const [evalResult, context] = await Promise.all([listEvaluators({ region }), loadDeployedProjectConfig()]); + + 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) => { + setFlow({ name: 'running', config, progress: 'Starting batch evaluation...' }); + }, []); + + // Execute batch evaluation + useEffect(() => { + if (flow.name !== 'running') return; + let cancelled = false; + + const { config } = flow; + + void (async () => { + try { + const result = await runBatchEvaluationCommand({ + agent: config.agent, + evaluators: config.evaluators, + name: config.name || undefined, + onProgress: (_status, message) => { + if (!cancelled) setFlow(prev => (prev.name === 'running' ? { ...prev, progress: message } : prev)); + }, + }); + + if (cancelled) return; + + // Save results locally + if (result.success) { + try { + saveBatchEvalRun(result); + } catch { + // Non-fatal + } + } + + if (!result.success) { + setFlow({ name: 'error', message: result.error ?? 'Batch evaluation failed' }); + return; + } + + setFlow({ name: 'results', result }); + } 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 ( + + ); + } + + if (flow.name === 'running') { + return ( + + + + ); + } + + 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', 'name', 'confirm'] : ['agent', 'evaluators', 'name', 'confirm']), + [skipAgent] + ); + + const [step, setStep] = useState(allSteps[0]!); + const [config, setConfig] = useState({ + agent: skipAgent ? agents[0]!.name : '', + evaluators: [], + evaluatorNames: [], + name: '', + }); + + const currentIndex = allSteps.indexOf(step); + + const goBack = useCallback(() => { + const prev = allSteps[currentIndex - 1]; + if (prev) 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] + ); + + const isAgentStep = step === 'agent'; + const isEvaluatorsStep = step === 'evaluators'; + const isNameStep = step === 'name'; + const isConfirmStep = step === 'confirm'; + + 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, + }); + + 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' + : isNameStep + ? HELP_TEXT.TEXT_INPUT + : HELP_TEXT.CONFIRM_CANCEL; + + const headerContent = ; + + return ( + + + {isAgentStep && ( + + )} + + {isEvaluatorsStep && ( + + )} + + {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; + onRunAnother: () => void; + onExit: () => void; +} + +function ResultsView({ result, 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, + }); + + // Group results by evaluator + const byEvaluator = useMemo(() => { + 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]); + + return ( + + + + ✓ Batch evaluation complete + + ID: {result.batchEvaluateId} + {' '} + Status: {result.status} + + {result.name && ( + + Name: {result.name} + + )} + + {result.results.length > 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. + + )} + + {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..a0df9ce8c 100644 --- a/src/cli/tui/screens/run-eval/RunScreen.tsx +++ b/src/cli/tui/screens/run-eval/RunScreen.tsx @@ -6,10 +6,11 @@ import React, { useMemo } from 'react'; interface RunScreenProps { onRunEval: () => void; + onRunBatchEval: () => void; onExit: () => void; } -export function RunScreen({ onRunEval, onExit }: RunScreenProps) { +export function RunScreen({ onRunEval, onRunBatchEval, onExit }: RunScreenProps) { const items: SelectableItem[] = useMemo( () => [ { @@ -17,13 +18,21 @@ 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.', + }, ], [] ); const nav = useListNavigation({ items, - onSelect: () => onRunEval(), + onSelect: item => { + if (item.id === 'run-eval') onRunEval(); + else if (item.id === 'run-batch-eval') onRunBatchEval(); + }, 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..c70fb1d14 100644 --- a/src/cli/tui/screens/run-eval/index.ts +++ b/src/cli/tui/screens/run-eval/index.ts @@ -1,3 +1,4 @@ +export { RunBatchEvalFlow } from './RunBatchEvalFlow'; export { RunEvalFlow } from './RunEvalFlow'; export { RunEvalScreen } from './RunEvalScreen'; export { RunScreen } from './RunScreen'; From 0bb7ad5ad9eb6e17a53bdea87079dfc61c0c8b5c Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:40:45 -0400 Subject: [PATCH 16/64] =?UTF-8?q?fix:=20evo=20cleanup=20=E2=80=94=20sync?= =?UTF-8?q?=20public=200.7.1=20+=206=20bug=20fixes=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .github/workflows/lint.yml | 17 + .github/workflows/pr-tarball.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 45 ++ docs/PERMISSIONS.md | 11 +- docs/commands.md | 31 +- docs/policies/iam-policy-user.json | 1 + package-lock.json | 304 ++++----- package.json | 2 +- schemas/agentcore.schema.v1.json | 66 +- .../assets.snapshot.test.ts.snap | 3 + .../python-lambda/execution-role-policy.json | 10 + .../python-lambda/lambda_function.py | 19 + .../evaluators/python-lambda/pyproject.toml | 15 + .../aws/__tests__/agentcore-control.test.ts | 197 ++++++ src/cli/aws/agentcore-batch-evaluation.ts | 23 + src/cli/aws/agentcore-config-bundles.ts | 19 +- src/cli/aws/agentcore-control.ts | 593 ++++++++++++++++- src/cli/aws/agentcore-recommendation.ts | 2 +- src/cli/aws/agentcore.ts | 92 ++- src/cli/aws/index.ts | 5 + src/cli/commands/create/action.ts | 13 +- src/cli/commands/create/command.tsx | 16 +- src/cli/commands/create/types.ts | 1 + src/cli/commands/dev/command.tsx | 43 ++ .../fetch/__tests__/fetch-access.test.ts | 20 + src/cli/commands/fetch/action.ts | 10 +- src/cli/commands/fetch/command.tsx | 1 + src/cli/commands/fetch/types.ts | 1 + .../import/__tests__/import-evaluator.test.ts | 437 +++++++++++++ .../__tests__/import-online-eval.test.ts | 368 +++++++++++ .../import-runtime-entrypoint.test.ts | 60 ++ .../__tests__/import-runtime-handler.test.ts | 612 ++++++++++++++++++ src/cli/commands/import/actions.ts | 360 +++-------- src/cli/commands/import/command.ts | 78 ++- src/cli/commands/import/constants.ts | 15 + src/cli/commands/import/import-evaluator.ts | 153 +++++ src/cli/commands/import/import-memory.ts | 131 ++++ src/cli/commands/import/import-online-eval.ts | 218 +++++++ src/cli/commands/import/import-pipeline.ts | 145 +++++ src/cli/commands/import/import-runtime.ts | 235 +++++++ src/cli/commands/import/import-utils.ts | 489 ++++++++++++++ src/cli/commands/import/phase2-import.ts | 12 + src/cli/commands/import/resource-import.ts | 247 +++++++ src/cli/commands/import/types.ts | 158 ++++- .../invoke/__tests__/validate.test.ts | 28 + src/cli/commands/invoke/action.ts | 104 ++- src/cli/commands/invoke/command.tsx | 14 +- src/cli/commands/invoke/types.ts | 6 +- src/cli/commands/invoke/validate.ts | 9 + src/cli/commands/run/command.tsx | 139 ++-- .../commands/status/__tests__/action.test.ts | 187 ++++++ src/cli/commands/status/action.ts | 12 +- src/cli/commands/status/command.tsx | 9 +- src/cli/commands/status/constants.ts | 5 + .../agent/generate/schema-mapper.ts | 2 + .../post-deploy-config-bundles.test.ts | 16 +- .../__tests__/preflight-container.test.ts | 28 + .../deploy/post-deploy-config-bundles.ts | 9 +- src/cli/operations/deploy/preflight.ts | 6 +- .../operations/dev/__tests__/config.test.ts | 30 + src/cli/operations/dev/config.ts | 2 + .../operations/dev/container-dev-server.ts | 7 +- src/cli/operations/eval/batch-eval-storage.ts | 3 + .../operations/eval/run-batch-evaluation.ts | 7 +- .../__tests__/fetch-gateway-token.test.ts | 116 ++++ .../fetch-access/fetch-gateway-token.ts | 3 +- .../fetch-access/fetch-runtime-token.ts | 10 +- .../operations/fetch-access/oauth-token.ts | 13 +- src/cli/primitives/ConfigBundlePrimitive.ts | 2 +- src/cli/primitives/EvaluatorPrimitive.ts | 132 +++- .../primitives/OnlineEvalConfigPrimitive.ts | 12 + .../__tests__/EvaluatorPrimitive.test.ts | 11 +- src/cli/templates/BaseRenderer.ts | 3 +- src/cli/templates/EvaluatorRenderer.ts | 12 + src/cli/templates/render.ts | 10 +- src/cli/templates/types.ts | 2 + src/cli/tui/App.tsx | 18 +- src/cli/tui/components/PathInput.tsx | 22 +- src/cli/tui/components/ResourceGraph.tsx | 8 + src/cli/tui/components/TextInput.tsx | 10 +- .../components/__tests__/PathInput.test.tsx | 17 + src/cli/tui/copy.ts | 61 +- src/cli/tui/hooks/useCreateEvaluator.ts | 2 +- src/cli/tui/hooks/useDevServer.ts | 99 ++- src/cli/tui/hooks/useTextInput.ts | 11 +- src/cli/tui/screens/agent/AddAgentScreen.tsx | 225 +++++-- .../agent/__tests__/computeByoSteps.test.ts | 62 ++ src/cli/tui/screens/agent/types.ts | 4 + src/cli/tui/screens/agent/useAddAgent.ts | 30 +- .../config-bundle-hub/useConfigBundleHub.ts | 75 ++- .../config-bundle/AddConfigBundleFlow.tsx | 2 +- .../config-bundle/AddConfigBundleScreen.tsx | 2 +- .../config-bundle/useAddConfigBundleWizard.ts | 2 +- src/cli/tui/screens/create/useCreateFlow.ts | 3 + src/cli/tui/screens/dev/DevScreen.tsx | 160 ++++- .../screens/evaluator/AddEvaluatorFlow.tsx | 10 +- .../screens/evaluator/AddEvaluatorScreen.tsx | 158 ++++- src/cli/tui/screens/evaluator/types.ts | 46 +- .../evaluator/useAddEvaluatorWizard.ts | 246 +++++-- .../tui/screens/generate/GenerateWizardUI.tsx | 76 ++- .../__tests__/useGenerateWizard.test.tsx | 180 +++++- src/cli/tui/screens/generate/types.ts | 39 +- .../tui/screens/generate/useGenerateWizard.ts | 215 ++++-- src/cli/tui/screens/import/ArnInputScreen.tsx | 47 ++ src/cli/tui/screens/import/CodePathScreen.tsx | 20 + src/cli/tui/screens/import/ImportFlow.tsx | 202 ++++++ .../screens/import/ImportProgressScreen.tsx | 100 +++ .../tui/screens/import/ImportSelectScreen.tsx | 58 ++ src/cli/tui/screens/import/YamlPathScreen.tsx | 26 + src/cli/tui/screens/import/index.ts | 1 + src/cli/tui/screens/invoke/InvokeScreen.tsx | 169 +++-- src/cli/tui/screens/invoke/useInvokeFlow.ts | 96 ++- .../screens/online-eval/AddOnlineEvalFlow.tsx | 17 +- .../recommendation/RecommendationScreen.tsx | 31 +- .../RecommendationsHubScreen.tsx | 7 +- .../recommendation/useRecommendationWizard.ts | 17 +- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 121 +++- src/cli/tui/utils/commands.ts | 2 +- src/lib/__tests__/constants.test.ts | 29 +- src/lib/constants.ts | 10 +- src/lib/packaging/__tests__/container.test.ts | 32 + src/lib/packaging/container.ts | 9 +- src/lib/packaging/index.ts | 2 +- src/lib/packaging/node.ts | 2 +- src/lib/packaging/python.ts | 2 +- src/schema/llm-compacted/agentcore.ts | 3 +- .../schemas/__tests__/agent-env.test.ts | 82 +++ src/schema/schemas/agent-env.ts | 19 +- src/schema/schemas/agentcore-project.ts | 21 +- src/schema/schemas/primitives/evaluator.ts | 44 +- src/schema/schemas/primitives/index.ts | 16 +- 132 files changed, 8114 insertions(+), 1085 deletions(-) create mode 100644 src/assets/evaluators/python-lambda/execution-role-policy.json create mode 100644 src/assets/evaluators/python-lambda/lambda_function.py create mode 100644 src/assets/evaluators/python-lambda/pyproject.toml create mode 100644 src/cli/commands/import/__tests__/import-evaluator.test.ts create mode 100644 src/cli/commands/import/__tests__/import-online-eval.test.ts create mode 100644 src/cli/commands/import/__tests__/import-runtime-entrypoint.test.ts create mode 100644 src/cli/commands/import/__tests__/import-runtime-handler.test.ts create mode 100644 src/cli/commands/import/import-evaluator.ts create mode 100644 src/cli/commands/import/import-memory.ts create mode 100644 src/cli/commands/import/import-online-eval.ts create mode 100644 src/cli/commands/import/import-pipeline.ts create mode 100644 src/cli/commands/import/import-runtime.ts create mode 100644 src/cli/commands/import/import-utils.ts create mode 100644 src/cli/commands/import/resource-import.ts create mode 100644 src/cli/templates/EvaluatorRenderer.ts create mode 100644 src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts create mode 100644 src/cli/tui/screens/import/ArnInputScreen.tsx create mode 100644 src/cli/tui/screens/import/CodePathScreen.tsx create mode 100644 src/cli/tui/screens/import/ImportFlow.tsx create mode 100644 src/cli/tui/screens/import/ImportProgressScreen.tsx create mode 100644 src/cli/tui/screens/import/ImportSelectScreen.tsx create mode 100644 src/cli/tui/screens/import/YamlPathScreen.tsx create mode 100644 src/cli/tui/screens/import/index.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 51607f2a2..24a9317f6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -100,3 +100,20 @@ jobs: path: node_modules key: node-modules-${{ hashFiles('package-lock.json') }} - run: npm run typecheck + + schema-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Reject schema changes outside release PRs + run: | + if git diff --name-only origin/main...HEAD | grep -q '^schemas/agentcore\.schema\.v[0-9]*\.json$'; then + echo "" + echo "❌ schemas/ must not be modified directly." + echo "The JSON schema is served live from the repo — changes are released automatically." + echo "Schema regeneration happens during the release workflow." + exit 1 + fi + echo "✓ No schema changes detected" diff --git a/.github/workflows/pr-tarball.yml b/.github/workflows/pr-tarball.yml index 60d899c03..3c5c5c522 100644 --- a/.github/workflows/pr-tarball.yml +++ b/.github/workflows/pr-tarball.yml @@ -67,7 +67,7 @@ jobs: "${TARBALL_NAME}" \ --title "PR #${PR_NUMBER} Tarball" \ --notes "Auto-generated tarball for PR #${PR_NUMBER}." \ - --prerelease \ + --draft \ --target "${{ github.event.pull_request.head.sha }}" DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${TARBALL_NAME}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f9211192..21afc4e1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -317,7 +317,7 @@ jobs: - name: Ensure npm 11.5.1+ for trusted publishing run: | echo "Current npm version: $(npm --version)" - npm install -g npm@latest + npm install -g npm@11.5.1 echo "Updated npm version: $(npm --version)" - name: Download artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a7d4b63..2e1fc2714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ All notable changes to this project will be documented in this file. +## [0.7.1] - 2026-04-07 + +### Added +- feat: add custom dockerfile support for Container agent builds (#783) (cdd5a15) + +### Fixed +- fix: make add command description consistent with remove (#773) (2eb9edb) + +### Other Changes +- fix(ci): pin npm version to avoid self-upgrade corruption (#785) (9c10f2c) +- chore: bump version to 0.7.0 (#784) (a4f9948) +- feat(invoke,dev): add exec mode for running shell commands in runtimes (#750) (27ce2d0) +- feat(import): add evaluator and online eval config import subcommands (#780) (e266576) +- feat(create): add --skip-install flag to skip dependency installation (#782) (380ac6e) +- feat(status): display runtime invocation URL for deployed agents (#775) (0aa9d55) +- fix(fetch): add --identity-name option for custom credential lookup (#715) (#774) (a6bf024) +- chore(deps): bump vite from 8.0.3 to 8.0.5 (#777) (c9e5cfe) + +## [0.7.0] - 2026-04-07 + +### Added +- feat: add custom dockerfile support for Container agent builds (#783) (cdd5a15) + +### Fixed +- fix: make add command description consistent with remove (#773) (2eb9edb) + +### Other Changes +- feat(invoke,dev): add exec mode for running shell commands in runtimes (#750) (27ce2d0) +- feat(import): add evaluator and online eval config import subcommands (#780) (e266576) +- feat(create): add --skip-install flag to skip dependency installation (#782) (380ac6e) +- feat(status): display runtime invocation URL for deployed agents (#775) (0aa9d55) +- fix(fetch): add --identity-name option for custom credential lookup (#715) (#774) (a6bf024) +- chore(deps): bump vite from 8.0.3 to 8.0.5 (#777) (c9e5cfe) + +## [0.6.0] - 2026-04-02 + +### Added +- feat: add code-based evaluator support (#739) (11ca658) + +### Other Changes +- ci: block schema changes in PRs (#712) (8119910) +- fix(ci): regenerate lockfile for npm 11 compatibility (#770) (ee7aea2) +- feat(import): add runtime and memory import subcommands with TUI wizard (#763) (cb79649) +- ci: use draft releases for PR tarballs to avoid notifying watchers (#745) (1a45c28) + ## [0.5.1] - 2026-03-31 ### Added diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 7895513e6..53ed2958d 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -311,11 +311,12 @@ Required for all deployment operations (`deploy`, `status`, `diff`). ### Agent invocation -| Action | CLI Commands | Purpose | -| --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- | -| `bedrock-agentcore:InvokeAgentRuntime` | `invoke` | Invoke deployed agents (HTTP, MCP, and A2A protocols) | -| `bedrock-agentcore:InvokeAgentRuntimeForUser` | `invoke` | Invoke agents with a user ID (requires `X-Amzn-Bedrock-AgentCore-Runtime-User-Id` header) | -| `bedrock-agentcore:StopRuntimeSession` | `invoke` | End an agent runtime session | +| Action | CLI Commands | Purpose | +| --------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------- | +| `bedrock-agentcore:InvokeAgentRuntime` | `invoke` | Invoke deployed agents (HTTP, MCP, and A2A protocols) | +| `bedrock-agentcore:InvokeAgentRuntimeForUser` | `invoke` | Invoke agents with a user ID (requires `X-Amzn-Bedrock-AgentCore-Runtime-User-Id` header) | +| `bedrock-agentcore:InvokeAgentRuntimeCommand` | `invoke --exec` | Execute shell commands in a runtime container | +| `bedrock-agentcore:StopRuntimeSession` | `invoke` | End an agent runtime session | ### Runtime and resource status diff --git a/docs/commands.md b/docs/commands.md index 41e58ee92..4ffd6afc9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -505,20 +505,27 @@ agentcore invoke --json # JSON output # MCP protocol invoke agentcore invoke call-tool --tool myTool --input '{"key": "value"}' + +# Execute shell commands in the runtime container +agentcore invoke --exec "ls -la /app" +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) | -| `--agent ` | Specific agent | -| `--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`) | -| `--json` | JSON output | +| Flag | Description | +| --------------------- | -------------------------------------------------------- | +| `[prompt]` | Prompt text (positional argument) | +| `--prompt ` | Prompt text (flag, takes precedence over positional) | +| `--agent ` | Specific agent | +| `--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`) | +| `--exec` | Execute a shell command in the runtime container | +| `--timeout ` | Timeout in seconds for `--exec` commands | +| `--json` | JSON output | --- diff --git a/docs/policies/iam-policy-user.json b/docs/policies/iam-policy-user.json index d5fb58556..d2467a134 100644 --- a/docs/policies/iam-policy-user.json +++ b/docs/policies/iam-policy-user.json @@ -36,6 +36,7 @@ "Action": [ "bedrock-agentcore:InvokeAgentRuntime", "bedrock-agentcore:InvokeAgentRuntimeForUser", + "bedrock-agentcore:InvokeAgentRuntimeCommand", "bedrock-agentcore:Evaluate", "bedrock-agentcore:StopRuntimeSession" ], diff --git a/package-lock.json b/package-lock.json index 8e902e00f..e00dfdcfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.5.1", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.5.1", + "version": "0.7.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -110,9 +110,9 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.263", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.263.tgz", - "integrity": "sha512-X9JvcJhYcb7PHs8R7m4zMablO5C9PGb/hYfLnxds9h/rKJu6l7MiXE/SabCibuehxPnuO/vk+sVVJiUWrccarQ==", + "version": "2.2.273", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.273.tgz", + "integrity": "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg==", "dev": true, "license": "Apache-2.0" }, @@ -826,24 +826,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1020.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1020.0.tgz", - "integrity": "sha512-lHcrS7raDibEs8zGO5tMCNCikvbvibmgHfZZL1pvz89Qdg+aQYtknhfd4kyKuMH8zrJ2re0AWVgxCpBSfGqJNA==", + "version": "3.1023.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1023.0.tgz", + "integrity": "sha512-bjHJzc7e4PF73aequBzyfZY9l7+hXd0rK50vXk5IChExhlp91C8ooee9Y5nk6oEV4QO54gX0ZRukmUIvE6Be4w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", - "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.13", + "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/eventstream-serde-browser": "^4.2.12", @@ -854,7 +854,7 @@ "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-retry": "^4.4.46", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", @@ -870,7 +870,7 @@ "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -880,24 +880,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1020.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1020.0.tgz", - "integrity": "sha512-ucH65hymNXnkL7YtNt5OHV6cw61m2bVxbB5RVsCeN7wAtLhbFqK5yC+YIG5iQNZSs97TJ5AchCKDSj01PcMkQQ==", + "version": "3.1023.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1023.0.tgz", + "integrity": "sha512-O7YccWAoPDvxp853aklKmslJsau5pkN1UtieMDSy6G/71b6IGdqxNW6OEqBRbGocQoCQESzu6CbskYncOOfd0Q==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", - "@aws-sdk/credential-provider-node": "^3.972.28", + "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.13", + "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", @@ -905,7 +905,7 @@ "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-retry": "^4.4.46", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", @@ -921,7 +921,7 @@ "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" @@ -2165,19 +2165,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.27.tgz", - "integrity": "sha512-Um26EsNSUfVUX0wUXnUA1W3wzKhVy6nviEElsh5lLZUYj9bk6DXOPnpte0gt+WHubcVfVsRk40bbm4KaroTEag==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz", + "integrity": "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-env": "^3.972.24", "@aws-sdk/credential-provider-http": "^3.972.26", - "@aws-sdk/credential-provider-login": "^3.972.27", + "@aws-sdk/credential-provider-login": "^3.972.28", "@aws-sdk/credential-provider-process": "^3.972.24", - "@aws-sdk/credential-provider-sso": "^3.972.27", - "@aws-sdk/credential-provider-web-identity": "^3.972.27", - "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", @@ -2190,13 +2190,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.27.tgz", - "integrity": "sha512-t3ehEtHomGZwg5Gixw4fYbYtG9JBnjfAjSDabxhPEu/KLLUp0BB37/APX7MSKXQhX6ZH7pseuACFJ19NrAkNdg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz", + "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", @@ -2209,17 +2209,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.28.tgz", - "integrity": "sha512-rren+P6k5rShG5PX61iVi40kKdueyuMLBRTctQbyR5LooO9Ygr5L6R7ilG7RF1957NSH3KC3TU206fZuKwjSpQ==", + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz", + "integrity": "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.24", "@aws-sdk/credential-provider-http": "^3.972.26", - "@aws-sdk/credential-provider-ini": "^3.972.27", + "@aws-sdk/credential-provider-ini": "^3.972.28", "@aws-sdk/credential-provider-process": "^3.972.24", - "@aws-sdk/credential-provider-sso": "^3.972.27", - "@aws-sdk/credential-provider-web-identity": "^3.972.27", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", @@ -2249,14 +2249,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.27.tgz", - "integrity": "sha512-CWXeGjlbBuHcm9appZUgXKP2zHDyTti0/+gXpSFJ2J3CnSwf1KWjicjN0qG2ozkMH6blrrzMrimeIOEYNl238Q==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz", + "integrity": "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.17", - "@aws-sdk/token-providers": "3.1020.0", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/token-providers": "3.1021.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -2268,13 +2268,13 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1020.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1020.0.tgz", - "integrity": "sha512-T61KA/VKl0zVUubdxigr1ut7SEpwE1/4CIKb14JDLyTAOne2yWKtQE1dDCSHl0UqrZNwW/bTt+EBHfQbslZJdw==", + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz", + "integrity": "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -2286,13 +2286,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.27.tgz", - "integrity": "sha512-CUY4hQIFswdQNEsRGEzGBUKGMK5KpqmNDdu2ROMgI+45PLFS8H0y3Tm7kvM16uvvw3n1pVxk85tnRVUTgtaa1w==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz", + "integrity": "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.17", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -2594,9 +2594,9 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.27.tgz", - "integrity": "sha512-TIRLO5UR2+FVUGmhYoAwVkKhcVzywEDX/5LzR9tjy1h8FQAXOtFg2IqgmwvxU7y933rkTn9rl6AdgcAUgQ1/Kg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz", + "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.26", @@ -2605,7 +2605,7 @@ "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "tslib": "^2.6.2" }, "engines": { @@ -2636,9 +2636,9 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.17.tgz", - "integrity": "sha512-7B0HIX0tEFmOSJuWzdHZj1WhMXSryM+h66h96ZkqSncoY7J6wq61KOu4Kr57b/YnJP3J/EeQYVFulgR281h+7A==", + "version": "3.996.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz", + "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -2647,12 +2647,12 @@ "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.13", + "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", @@ -2660,7 +2660,7 @@ "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-retry": "^4.4.45", + "@smithy/middleware-retry": "^4.4.46", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", @@ -2676,7 +2676,7 @@ "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2816,12 +2816,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.13.tgz", - "integrity": "sha512-s1dCJ0J9WU9UPkT3FFqhKTSquYTkqWXGRaapHFyWwwJH86ZussewhNST5R5TwXVL1VSHq4aJVl9fWK+svaRVCQ==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz", + "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.27", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", @@ -3166,21 +3166,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "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==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "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, @@ -3189,9 +3189,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -4032,16 +4032,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.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "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": { @@ -4320,23 +4326,6 @@ "node": ">=14.0.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", @@ -4865,9 +4854,9 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.45", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.45.tgz", - "integrity": "sha512-td1PxpwDIaw5/oP/xIRxBGxJKoF1L4DBAwbZ8wjMuXBYOP/r2ZE/Ocou+mBHx/yk9knFEtDBwhSrYVn+Mz4pHw==", + "version": "4.4.46", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", + "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.12", @@ -4876,7 +4865,7 @@ "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -5219,9 +5208,9 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", - "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz", + "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==", "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.12", @@ -5977,6 +5966,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", @@ -6592,9 +6594,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.244.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.244.0.tgz", - "integrity": "sha512-j5FVeZv5W+v6j6OnW8RjoN04T+8pYvDJJV7yXhhj4IiGDKPgMH3fflQLQXJousd2QQk+nSAjghDVJcrZ4GFyGA==", + "version": "2.247.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.247.0.tgz", + "integrity": "sha512-jwGmLg3qycFx0G+uEhoqk6pzSg6BAiaCQpuUreHUE4BnrhcUEG202BZ+PL8oU943fDjlI/xuwaS+Icru3fecYQ==", "bundleDependencies": [ "@balena/dockerignore", "@aws-cdk/cloud-assembly-api", @@ -6612,10 +6614,10 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.263", + "@aws-cdk/asset-awscli-v1": "2.2.273", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", - "@aws-cdk/cloud-assembly-api": "^2.1.1", - "@aws-cdk/cloud-assembly-schema": "^52.1.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", @@ -6626,7 +6628,7 @@ "punycode": "^2.3.1", "semver": "^7.7.4", "table": "^6.9.0", - "yaml": "1.10.2" + "yaml": "1.10.3" }, "engines": { "node": ">= 20.0.0" @@ -6636,7 +6638,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.1.1", + "version": "2.2.0", "bundleDependencies": [ "jsonschema", "semver" @@ -6646,13 +6648,13 @@ "license": "Apache-2.0", "dependencies": { "jsonschema": "~1.4.1", - "semver": "^7.7.3" + "semver": "^7.7.4" }, "engines": { "node": ">= 18.0.0" }, "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=52.1.0" + "@aws-cdk/cloud-assembly-schema": ">=53.0.0" } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { @@ -6665,47 +6667,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { - "version": "7.7.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "52.2.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-52.2.0.tgz", - "integrity": "sha512-ourZjixQ/UfsZc7gdk3vt1eHBODMUjQTYYYCY3ZX8fiXyHtWNDAYZPrXUK96jpCC2fLP+tfHTJrBjZ563pmcEw==", - "bundleDependencies": [ - "jsonschema", - "semver" - ], - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "~1.4.1", - "semver": "^7.7.3" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { - "version": "1.4.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.7.3", + "version": "7.7.4", "dev": true, "inBundle": true, "license": "ISC", @@ -6781,7 +6743,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.3", + "version": "5.0.5", "dev": true, "inBundle": true, "license": "MIT", @@ -6940,12 +6902,12 @@ } }, "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "10.2.4", + "version": "10.2.5", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -7053,7 +7015,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.2", + "version": "1.10.3", "dev": true, "inBundle": true, "license": "ISC", @@ -11120,15 +11082,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "dev": true, "license": "MIT" }, @@ -14231,9 +14193,9 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", + "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14258,7 +14220,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/package.json b/package.json index 5312f12b1..4955c1359 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.5.1", + "version": "0.7.1", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 8a0cdd309..1d74ecd66 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -47,6 +47,10 @@ "maxLength": 48, "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,47}$" }, + "description": { + "type": "string", + "maxLength": 200 + }, "build": { "type": "string", "enum": ["CodeZip", "Container"] @@ -60,6 +64,12 @@ "type": "string", "minLength": 1 }, + "dockerfile": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, "runtimeVersion": { "anyOf": [ { @@ -272,7 +282,7 @@ "additionalProperties": false } }, - "required": ["name", "build", "entrypoint", "codeLocation", "runtimeVersion"], + "required": ["name", "build", "entrypoint", "codeLocation"], "additionalProperties": false } }, @@ -342,6 +352,12 @@ "maxLength": 256, "pattern": "^[\\p{L}\\p{N}\\s_.:/=+\\-@]*$" } + }, + "encryptionKeyArn": { + "type": "string" + }, + "executionRoleArn": { + "type": "string" } }, "required": ["name", "eventExpiryDuration"], @@ -494,9 +510,55 @@ }, "required": ["model", "instructions", "ratingScale"], "additionalProperties": false + }, + "codeBased": { + "type": "object", + "properties": { + "managed": { + "type": "object", + "properties": { + "codeLocation": { + "type": "string", + "minLength": 1 + }, + "entrypoint": { + "default": "lambda_function.handler", + "type": "string", + "minLength": 1 + }, + "timeoutSeconds": { + "default": 60, + "type": "integer", + "minimum": 1, + "maximum": 300 + }, + "additionalPolicies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["codeLocation"], + "additionalProperties": false + }, + "external": { + "type": "object", + "properties": { + "lambdaArn": { + "type": "string", + "minLength": 1, + "pattern": "^arn:aws[a-z-]*:lambda:[a-z0-9-]+:\\d{12}:function:.+$" + } + }, + "required": ["lambdaArn"], + "additionalProperties": false + } + }, + "additionalProperties": false } }, - "required": ["llmAsAJudge"], "additionalProperties": false }, "tags": { diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index ce1b00fcf..fa22b6dfb 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -446,6 +446,9 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "cdk/tsconfig.json", "container/python/Dockerfile", "container/python/dockerignore.template", + "evaluators/python-lambda/execution-role-policy.json", + "evaluators/python-lambda/lambda_function.py", + "evaluators/python-lambda/pyproject.toml", "mcp/python-lambda/README.md", "mcp/python-lambda/handler.py", "mcp/python-lambda/pyproject.toml", diff --git a/src/assets/evaluators/python-lambda/execution-role-policy.json b/src/assets/evaluators/python-lambda/execution-role-policy.json new file mode 100644 index 000000000..6a49ca7d9 --- /dev/null +++ b/src/assets/evaluators/python-lambda/execution-role-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/*" + } + ] +} diff --git a/src/assets/evaluators/python-lambda/lambda_function.py b/src/assets/evaluators/python-lambda/lambda_function.py new file mode 100644 index 000000000..0f8bd5c6b --- /dev/null +++ b/src/assets/evaluators/python-lambda/lambda_function.py @@ -0,0 +1,19 @@ +from bedrock_agentcore.evaluation.custom_code_based_evaluators import ( + custom_code_based_evaluator, + EvaluatorInput, + EvaluatorOutput, +) + + +@custom_code_based_evaluator() +def handler(input: EvaluatorInput, context) -> EvaluatorOutput: + """Evaluate agent behavior with custom logic. + + Args: + input: Contains evaluation_level, session_spans, target_trace_id, target_span_id + + Returns: + EvaluatorOutput with value/label for success, or errorCode/errorMessage for failure. + """ + # TODO: Replace with your evaluation logic + return EvaluatorOutput(value=1.0, label="Pass", explanation="Evaluation passed") diff --git a/src/assets/evaluators/python-lambda/pyproject.toml b/src/assets/evaluators/python-lambda/pyproject.toml new file mode 100644 index 000000000..69ad99b43 --- /dev/null +++ b/src/assets/evaluators/python-lambda/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ Name }}" +version = "0.1.0" +description = "AgentCore Code-Based Evaluator" +requires-python = ">=3.10" +dependencies = [ + "bedrock-agentcore>=1.6.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/cli/aws/__tests__/agentcore-control.test.ts b/src/cli/aws/__tests__/agentcore-control.test.ts index 3683eb081..34351c3cc 100644 --- a/src/cli/aws/__tests__/agentcore-control.test.ts +++ b/src/cli/aws/__tests__/agentcore-control.test.ts @@ -1,7 +1,10 @@ import { + getAgentRuntimeDetail, getAgentRuntimeStatus, getEvaluator, getOnlineEvaluationConfig, + listAllAgentRuntimes, + listAllMemories, listEvaluators, updateOnlineEvalExecutionStatus, } from '../agentcore-control.js'; @@ -24,9 +27,18 @@ vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ GetOnlineEvaluationConfigCommand: class { constructor(public input: unknown) {} }, + ListAgentRuntimesCommand: class { + constructor(public input: unknown) {} + }, + ListMemoriesCommand: class { + constructor(public input: unknown) {} + }, ListEvaluatorsCommand: class { constructor(public input: unknown) {} }, + ListTagsForResourceCommand: class { + constructor(public input: unknown) {} + }, UpdateOnlineEvaluationConfigCommand: class { constructor(public input: unknown) {} }, @@ -305,6 +317,191 @@ describe('getOnlineEvaluationConfig', () => { }); }); +describe('listAllAgentRuntimes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns all runtimes from a single page', async () => { + mockSend.mockResolvedValue({ + agentRuntimes: [ + { agentRuntimeId: 'rt-1', agentRuntimeArn: 'arn-1', agentRuntimeName: 'runtime-1', status: 'READY' }, + ], + nextToken: undefined, + }); + + const result = await listAllAgentRuntimes({ region: 'us-east-1' }); + expect(result).toHaveLength(1); + expect(result[0]!.agentRuntimeId).toBe('rt-1'); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('paginates across multiple pages', async () => { + mockSend + .mockResolvedValueOnce({ + agentRuntimes: [{ agentRuntimeId: 'rt-1', agentRuntimeArn: 'arn-1', agentRuntimeName: 'r1', status: 'READY' }], + nextToken: 'page2', + }) + .mockResolvedValueOnce({ + agentRuntimes: [{ agentRuntimeId: 'rt-2', agentRuntimeArn: 'arn-2', agentRuntimeName: 'r2', status: 'READY' }], + nextToken: 'page3', + }) + .mockResolvedValueOnce({ + agentRuntimes: [{ agentRuntimeId: 'rt-3', agentRuntimeArn: 'arn-3', agentRuntimeName: 'r3', status: 'READY' }], + nextToken: undefined, + }); + + const result = await listAllAgentRuntimes({ region: 'us-east-1' }); + expect(result).toHaveLength(3); + expect(result.map(r => r.agentRuntimeId)).toEqual(['rt-1', 'rt-2', 'rt-3']); + expect(mockSend).toHaveBeenCalledTimes(3); + }); + + it('returns empty array when no runtimes exist', async () => { + mockSend.mockResolvedValue({ agentRuntimes: undefined, nextToken: undefined }); + + const result = await listAllAgentRuntimes({ region: 'us-east-1' }); + expect(result).toEqual([]); + }); +}); + +describe('listAllMemories', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns all memories from a single page', async () => { + mockSend.mockResolvedValue({ + memories: [{ id: 'mem-1', arn: 'arn-1', status: 'ACTIVE' }], + nextToken: undefined, + }); + + const result = await listAllMemories({ region: 'us-east-1' }); + expect(result).toHaveLength(1); + expect(result[0]!.memoryId).toBe('mem-1'); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('paginates across multiple pages', async () => { + mockSend + .mockResolvedValueOnce({ + memories: [{ id: 'mem-1', arn: 'arn-1', status: 'ACTIVE' }], + nextToken: 'page2', + }) + .mockResolvedValueOnce({ + memories: [{ id: 'mem-2', arn: 'arn-2', status: 'ACTIVE' }], + nextToken: undefined, + }); + + const result = await listAllMemories({ region: 'us-east-1' }); + expect(result).toHaveLength(2); + expect(result.map(m => m.memoryId)).toEqual(['mem-1', 'mem-2']); + expect(mockSend).toHaveBeenCalledTimes(2); + }); + + it('returns empty array when no memories exist', async () => { + mockSend.mockResolvedValue({ memories: undefined, nextToken: undefined }); + + const result = await listAllMemories({ region: 'us-east-1' }); + expect(result).toEqual([]); + }); +}); + +describe('getAgentRuntimeDetail — new fields', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const baseResponse = { + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'my-runtime', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test', + networkConfiguration: { networkMode: 'PUBLIC' }, + protocolConfiguration: { serverProtocol: 'HTTP' }, + agentRuntimeArtifact: { codeConfiguration: { runtime: 'PYTHON_3_12', entryPoint: ['main.py'] } }, + }; + + it('extracts environmentVariables when present', async () => { + mockSend.mockResolvedValue({ + ...baseResponse, + environmentVariables: { API_KEY: 'secret', DB_HOST: 'localhost' }, + }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.environmentVariables).toEqual({ API_KEY: 'secret', DB_HOST: 'localhost' }); + }); + + it('returns undefined environmentVariables when empty', async () => { + mockSend.mockResolvedValue({ ...baseResponse, environmentVariables: {} }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.environmentVariables).toBeUndefined(); + }); + + it('extracts lifecycleConfiguration when present', async () => { + mockSend.mockResolvedValue({ + ...baseResponse, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }, + }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }); + }); + + it('returns undefined lifecycleConfiguration when absent', async () => { + mockSend.mockResolvedValue({ ...baseResponse }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.lifecycleConfiguration).toBeUndefined(); + }); + + it('extracts requestHeaderAllowlist from requestHeaderConfiguration union', async () => { + mockSend.mockResolvedValue({ + ...baseResponse, + requestHeaderConfiguration: { + requestHeaderAllowlist: ['X-Custom-Header', 'Authorization'], + }, + }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.requestHeaderAllowlist).toEqual(['X-Custom-Header', 'Authorization']); + }); + + it('returns undefined requestHeaderAllowlist when not present', async () => { + mockSend.mockResolvedValue({ ...baseResponse }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.requestHeaderAllowlist).toBeUndefined(); + }); + + it('fetches tags via ListTagsForResource', async () => { + // First call: GetAgentRuntime, second call: ListTagsForResource + mockSend + .mockResolvedValueOnce({ ...baseResponse }) + .mockResolvedValueOnce({ tags: { env: 'prod', team: 'platform' } }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.tags).toEqual({ env: 'prod', team: 'platform' }); + expect(mockSend).toHaveBeenCalledTimes(2); + }); + + it('returns undefined tags when ListTagsForResource returns empty', async () => { + mockSend.mockResolvedValueOnce({ ...baseResponse }).mockResolvedValueOnce({ tags: {} }); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.tags).toBeUndefined(); + }); + + it('returns undefined tags when ListTagsForResource fails', async () => { + mockSend.mockResolvedValueOnce({ ...baseResponse }).mockRejectedValueOnce(new Error('AccessDenied')); + + const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' }); + expect(result.tags).toBeUndefined(); + }); +}); + describe('listEvaluators', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index f9b7a1dc8..52e38c1d6 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -70,6 +70,7 @@ export interface GetBatchEvaluationResult { logStreamName: string; }; }; + evaluationResults?: EvaluationResults; results?: BatchEvaluationResultEntry[]; errorDetails?: string[]; statusReasons?: string[]; @@ -83,6 +84,28 @@ export interface BatchEvaluationResultEntry { error?: string; } +export interface EvaluatorSummary { + evaluatorId: string; + statistics?: { + averageScore?: number; + averageTokenUsage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + }; + totalEvaluated?: number; + totalFailed?: number; +} + +export interface EvaluationResults { + evaluatorSummaries?: EvaluatorSummary[]; + sessionsCompleted?: number; + sessionsFailed?: number; + sessionsInProgress?: number; + totalSessions?: number; +} + export interface ListBatchEvaluationsOptions { region: string; maxResults?: number; diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index 2226db1d2..dae918bf1 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -301,19 +301,12 @@ export async function updateConfigurationBundle( return data as UpdateConfigurationBundleResult; } -export async function deleteConfigurationBundle( - options: DeleteConfigurationBundleOptions -): Promise<{ success: boolean; error?: string }> { - try { - await signedRequest({ - region: options.region, - method: 'DELETE', - path: `/configuration-bundles/${options.bundleId}`, - }); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } +export async function deleteConfigurationBundle(options: DeleteConfigurationBundleOptions): Promise { + await signedRequest({ + region: options.region, + method: 'DELETE', + path: `/configuration-bundles/${options.bundleId}`, + }); } export async function listConfigurationBundles( diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 63c560178..32d98610c 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -1,13 +1,76 @@ +import type { EvaluationLevel } from '../../schema/schemas/primitives/evaluator'; import { getCredentialProvider } from './account'; import { BedrockAgentCoreControlClient, GetAgentRuntimeCommand, GetEvaluatorCommand, + GetMemoryCommand, GetOnlineEvaluationConfigCommand, + ListAgentRuntimesCommand, ListEvaluatorsCommand, + ListMemoriesCommand, + ListOnlineEvaluationConfigsCommand, + ListTagsForResourceCommand, UpdateOnlineEvaluationConfigCommand, } from '@aws-sdk/client-bedrock-agentcore-control'; +/** + * Create a shared BedrockAgentCoreControlClient for the given region. + * 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 { + return new BedrockAgentCoreControlClient({ + region, + credentials: getCredentialProvider(), + }); +} + +/** + * Paginate through all pages of a list API and collect every item. + * Reuses a single client for connection pooling across pages. + */ +async function paginateAll( + region: string, + fetchPage: ( + options: { region: string; maxResults: number; nextToken?: string }, + client: BedrockAgentCoreControlClient + ) => Promise<{ items: T[]; nextToken?: string }> +): Promise { + const client = createControlClient(region); + const items: T[] = []; + let nextToken: string | undefined; + + do { + const result = await fetchPage({ region, maxResults: 100, nextToken }, client); + items.push(...result.items); + nextToken = result.nextToken; + } while (nextToken); + + return items; +} + +/** + * Fetch tags for a resource by ARN. Returns undefined when the ARN is missing, + * the resource has no tags, or the ListTagsForResource call fails. + */ +async function fetchTags( + client: BedrockAgentCoreControlClient, + resourceArn: string | undefined, + _resourceLabel: string +): Promise | undefined> { + if (!resourceArn) return undefined; + try { + const response = await client.send(new ListTagsForResourceCommand({ resourceArn })); + if (response.tags && Object.keys(response.tags).length > 0) { + return response.tags; + } + } catch { + // Non-fatal — tags are supplementary data; don't break listing if tag fetch fails + } + return undefined; +} + export interface GetAgentRuntimeStatusOptions { region: string; runtimeId: string; @@ -22,10 +85,7 @@ export interface AgentRuntimeStatusResult { * Fetch the status of an AgentCore Runtime by runtime ID. */ export async function getAgentRuntimeStatus(options: GetAgentRuntimeStatusOptions): Promise { - const client = new BedrockAgentCoreControlClient({ - region: options.region, - credentials: getCredentialProvider(), - }); + const client = createControlClient(options.region); const command = new GetAgentRuntimeCommand({ agentRuntimeId: options.runtimeId, @@ -43,6 +103,331 @@ export async function getAgentRuntimeStatus(options: GetAgentRuntimeStatusOption }; } +// ============================================================================ +// Agent Runtimes — List & Get +// ============================================================================ + +export interface ListAgentRuntimesOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface AgentRuntimeSummary { + agentRuntimeId: string; + agentRuntimeArn: string; + agentRuntimeName: string; + description: string; + status: string; + lastUpdatedAt?: Date; +} + +export interface ListAgentRuntimesResult { + runtimes: AgentRuntimeSummary[]; + nextToken?: string; +} + +/** + * List all AgentCore Runtimes in the given region. + */ +export async function listAgentRuntimes( + options: ListAgentRuntimesOptions, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListAgentRuntimesCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + runtimes: (response.agentRuntimes ?? []).map(r => ({ + agentRuntimeId: r.agentRuntimeId ?? '', + agentRuntimeArn: r.agentRuntimeArn ?? '', + agentRuntimeName: r.agentRuntimeName ?? '', + description: r.description ?? '', + status: r.status ?? 'UNKNOWN', + lastUpdatedAt: r.lastUpdatedAt, + })), + nextToken: response.nextToken, + }; +} + +/** + * List all AgentCore Runtimes in the given region, paginating through all pages. + */ +export async function listAllAgentRuntimes(options: { region: string }): Promise { + return paginateAll(options.region, async (opts, client) => { + const result = await listAgentRuntimes(opts, client); + return { items: result.runtimes, nextToken: result.nextToken }; + }); +} + +export interface GetAgentRuntimeOptions { + region: string; + runtimeId: string; +} + +export interface AgentRuntimeDetail { + agentRuntimeId: string; + agentRuntimeArn: string; + agentRuntimeName: string; + status: string; + description?: string; + roleArn: string; + networkMode: string; + networkConfig?: { subnets: string[]; securityGroups: string[] }; + protocol: string; + runtimeVersion?: string; + entryPoint?: string[]; + build: 'CodeZip' | 'Container'; + authorizerType?: string; + authorizerConfiguration?: { + customJwtAuthorizer?: { + discoveryUrl: string; + allowedAudience?: string[]; + allowedClients?: string[]; + allowedScopes?: string[]; + }; + }; + environmentVariables?: Record; + tags?: Record; + lifecycleConfiguration?: { idleRuntimeSessionTimeout?: number; maxLifetime?: number }; + requestHeaderAllowlist?: string[]; +} + +/** + * Get full details of an AgentCore Runtime by ID. + */ +export async function getAgentRuntimeDetail(options: GetAgentRuntimeOptions): Promise { + const client = createControlClient(options.region); + + const command = new GetAgentRuntimeCommand({ + agentRuntimeId: options.runtimeId, + }); + + const response = await client.send(command); + + const networkMode = response.networkConfiguration?.networkMode ?? 'PUBLIC'; + const networkConfig = + networkMode === 'VPC' && response.networkConfiguration?.networkModeConfig + ? { + subnets: response.networkConfiguration.networkModeConfig.subnets ?? [], + securityGroups: response.networkConfiguration.networkModeConfig.securityGroups ?? [], + } + : undefined; + + const isContainer = !!response.agentRuntimeArtifact?.containerConfiguration; + const codeConfig = response.agentRuntimeArtifact?.codeConfiguration; + + let authorizerType: string | undefined; + let authorizerConfiguration: AgentRuntimeDetail['authorizerConfiguration']; + if (response.authorizerConfiguration?.customJWTAuthorizer) { + authorizerType = 'CUSTOM_JWT'; + const jwt = response.authorizerConfiguration.customJWTAuthorizer; + authorizerConfiguration = { + customJwtAuthorizer: { + discoveryUrl: jwt.discoveryUrl ?? '', + allowedAudience: jwt.allowedAudience, + allowedClients: jwt.allowedClients, + allowedScopes: jwt.allowedScopes, + }, + }; + } + + // Extract environment variables + const environmentVariables = + response.environmentVariables && Object.keys(response.environmentVariables).length > 0 + ? response.environmentVariables + : undefined; + + // Extract lifecycle configuration + const lifecycleConfiguration = response.lifecycleConfiguration + ? { + idleRuntimeSessionTimeout: response.lifecycleConfiguration.idleRuntimeSessionTimeout, + maxLifetime: response.lifecycleConfiguration.maxLifetime, + } + : undefined; + + // Extract request header allowlist from the union type + let requestHeaderAllowlist: string[] | undefined; + if (response.requestHeaderConfiguration && 'requestHeaderAllowlist' in response.requestHeaderConfiguration) { + const allowlist = response.requestHeaderConfiguration.requestHeaderAllowlist; + if (allowlist && allowlist.length > 0) { + requestHeaderAllowlist = allowlist; + } + } + + const tags = await fetchTags(client, response.agentRuntimeArn, 'runtime'); + + return { + agentRuntimeId: response.agentRuntimeId ?? '', + agentRuntimeArn: response.agentRuntimeArn ?? '', + agentRuntimeName: response.agentRuntimeName ?? '', + status: response.status ?? 'UNKNOWN', + description: response.description, + roleArn: response.roleArn ?? '', + networkMode, + networkConfig, + protocol: response.protocolConfiguration?.serverProtocol ?? 'HTTP', + runtimeVersion: codeConfig?.runtime, + entryPoint: codeConfig?.entryPoint, + build: isContainer ? 'Container' : 'CodeZip', + authorizerType, + authorizerConfiguration, + environmentVariables, + tags, + lifecycleConfiguration, + requestHeaderAllowlist, + }; +} + +// ============================================================================ +// Memories — List & Get +// ============================================================================ + +export interface ListMemoriesOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface MemorySummary { + memoryId: string; + memoryArn: string; + status: string; + createdAt?: Date; + updatedAt?: Date; +} + +export interface ListMemoriesResult { + memories: MemorySummary[]; + nextToken?: string; +} + +/** + * List all AgentCore Memories in the given region. + */ +export async function listMemories( + options: ListMemoriesOptions, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListMemoriesCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + memories: (response.memories ?? []).map(m => ({ + memoryId: m.id ?? '', + memoryArn: m.arn ?? '', + status: m.status ?? 'UNKNOWN', + createdAt: m.createdAt, + updatedAt: m.updatedAt, + })), + nextToken: response.nextToken, + }; +} + +/** + * List all AgentCore Memories in the given region, paginating through all pages. + */ +export async function listAllMemories(options: { region: string }): Promise { + return paginateAll(options.region, async (opts, client) => { + const result = await listMemories(opts, client); + return { items: result.memories, nextToken: result.nextToken }; + }); +} + +export interface GetMemoryOptions { + region: string; + memoryId: string; +} + +export interface MemoryDetail { + memoryId: string; + memoryArn: string; + name: string; + status: string; + description?: string; + eventExpiryDuration: number; + strategies: { + type: string; + name?: string; + description?: string; + namespaces?: string[]; + reflectionNamespaces?: string[]; + }[]; + tags?: Record; + encryptionKeyArn?: string; + executionRoleArn?: string; +} + +/** + * Get full details of an AgentCore Memory by ID. + */ +export async function getMemoryDetail(options: GetMemoryOptions): Promise { + const client = createControlClient(options.region); + + const command = new GetMemoryCommand({ + memoryId: options.memoryId, + }); + + const response = await client.send(command); + const memory = response.memory; + + if (!memory) { + throw new Error(`No memory found for ID ${options.memoryId}`); + } + + if (!memory.id) { + throw new Error(`Memory ${options.memoryId} is missing required field: id`); + } + if (!memory.arn) { + throw new Error(`Memory ${options.memoryId} is missing required field: arn`); + } + if (!memory.name) { + throw new Error(`Memory ${options.memoryId} is missing required field: name`); + } + if (memory.eventExpiryDuration == null) { + throw new Error(`Memory ${options.memoryId} is missing required field: eventExpiryDuration`); + } + + const tags = await fetchTags(client, memory.arn, 'memory'); + + return { + memoryId: memory.id, + memoryArn: memory.arn, + name: memory.name, + status: memory.status ?? 'UNKNOWN', + description: memory.description, + eventExpiryDuration: memory.eventExpiryDuration, + tags, + encryptionKeyArn: memory.encryptionKeyArn, + executionRoleArn: memory.memoryExecutionRoleArn, + strategies: (memory.strategies ?? []).map(s => { + if (!s.type) { + throw new Error(`Memory ${options.memoryId} has a strategy with missing required field: type`); + } + const episodicNamespaces = s.configuration?.reflection?.episodicReflectionConfiguration?.namespaces; + return { + type: s.type, + name: s.name, + description: s.description, + namespaces: s.namespaces, + ...(episodicNamespaces && episodicNamespaces.length > 0 && { reflectionNamespaces: episodicNamespaces }), + }; + }), + }; +} + // ============================================================================ // Evaluator // ============================================================================ @@ -52,38 +437,109 @@ export interface GetEvaluatorOptions { evaluatorId: string; } +export interface GetEvaluatorLlmConfig { + model: string; + instructions: string; + ratingScale: { + numerical?: { value: number; label: string; definition: string }[]; + categorical?: { label: string; definition: string }[]; + }; +} + +export interface GetEvaluatorCodeBasedConfig { + lambdaArn: string; +} + export interface GetEvaluatorResult { evaluatorId: string; evaluatorArn: string; evaluatorName: string; - level: string; + level: EvaluationLevel; status: string; description?: string; + evaluatorConfig?: { + llmAsAJudge?: GetEvaluatorLlmConfig; + codeBased?: GetEvaluatorCodeBasedConfig; + }; + tags?: Record; } export async function getEvaluator(options: GetEvaluatorOptions): Promise { - const client = new BedrockAgentCoreControlClient({ - region: options.region, - credentials: getCredentialProvider(), - }); + const client = createControlClient(options.region); const command = new GetEvaluatorCommand({ evaluatorId: options.evaluatorId, }); - const response = await client.send(command); + let response; + try { + response = await client.send(command); + } catch (err: unknown) { + const name = (err as { name?: string }).name ?? ''; + if (name === 'ResourceNotFoundException' || name === 'ValidationException') { + throw new Error(`Evaluator "${options.evaluatorId}" not found. Verify the evaluator ID or ARN is correct.`); + } + throw err; + } if (!response.evaluatorId) { throw new Error(`No evaluator found for ID ${options.evaluatorId}`); } + // Map SDK evaluatorConfig union to flat optional-field format + let evaluatorConfig: GetEvaluatorResult['evaluatorConfig']; + if (response.evaluatorConfig) { + if ('llmAsAJudge' in response.evaluatorConfig && response.evaluatorConfig.llmAsAJudge) { + const llm = response.evaluatorConfig.llmAsAJudge; + // AWS API nests model ID under modelConfig.bedrockEvaluatorModelConfig.modelId; + // CLI schema flattens this to config.llmAsAJudge.model + let model = ''; + if ( + llm.modelConfig && + 'bedrockEvaluatorModelConfig' in llm.modelConfig && + llm.modelConfig.bedrockEvaluatorModelConfig + ) { + model = llm.modelConfig.bedrockEvaluatorModelConfig.modelId ?? ''; + } + const ratingScale: GetEvaluatorLlmConfig['ratingScale'] = {}; + if (llm.ratingScale) { + if ('numerical' in llm.ratingScale && llm.ratingScale.numerical) { + ratingScale.numerical = llm.ratingScale.numerical.map(n => ({ + value: n.value ?? 0, + label: n.label ?? '', + definition: n.definition ?? '', + })); + } else if ('categorical' in llm.ratingScale && llm.ratingScale.categorical) { + ratingScale.categorical = llm.ratingScale.categorical.map(c => ({ + label: c.label ?? '', + definition: c.definition ?? '', + })); + } + } + evaluatorConfig = { + llmAsAJudge: { model, instructions: llm.instructions ?? '', ratingScale }, + }; + } else if ('codeBased' in response.evaluatorConfig && response.evaluatorConfig.codeBased) { + const cb = response.evaluatorConfig.codeBased; + if ('lambdaConfig' in cb && cb.lambdaConfig) { + evaluatorConfig = { + codeBased: { lambdaArn: cb.lambdaConfig.lambdaArn ?? '' }, + }; + } + } + } + + const tags = await fetchTags(client, response.evaluatorArn, 'evaluator'); + return { evaluatorId: response.evaluatorId, evaluatorArn: response.evaluatorArn ?? '', evaluatorName: response.evaluatorName ?? '', - level: response.level ?? 'SESSION', + level: (response.level ?? 'SESSION') as EvaluationLevel, status: response.status ?? 'UNKNOWN', description: response.description, + evaluatorConfig, + tags, }; } @@ -108,18 +564,18 @@ export interface ListEvaluatorsResult { nextToken?: string; } -export async function listEvaluators(options: ListEvaluatorsOptions): Promise { - const client = new BedrockAgentCoreControlClient({ - region: options.region, - credentials: getCredentialProvider(), - }); +export async function listEvaluators( + options: ListEvaluatorsOptions, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); const command = new ListEvaluatorsCommand({ maxResults: options.maxResults, nextToken: options.nextToken, }); - const response = await client.send(command); + const response = await resolvedClient.send(command); return { evaluators: (response.evaluators ?? []).map(e => ({ @@ -135,8 +591,82 @@ export async function listEvaluators(options: ListEvaluatorsOptions): Promise
  • { + return paginateAll(options.region, async (opts, client) => { + const result = await listEvaluators(opts, client); + return { + items: result.evaluators.filter(e => !e.evaluatorName.startsWith('Builtin.')), + nextToken: result.nextToken, + }; + }); +} + // ============================================================================ -// Online Eval Config +// Online Eval Config — List +// ============================================================================ + +export interface ListOnlineEvalConfigsOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface OnlineEvalConfigSummary { + onlineEvaluationConfigId: string; + onlineEvaluationConfigArn: string; + onlineEvaluationConfigName: string; + description?: string; + status: string; + executionStatus: string; +} + +export interface ListOnlineEvalConfigsResult { + configs: OnlineEvalConfigSummary[]; + nextToken?: string; +} + +export async function listOnlineEvaluationConfigs( + options: ListOnlineEvalConfigsOptions, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListOnlineEvaluationConfigsCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + configs: (response.onlineEvaluationConfigs ?? []).map(c => ({ + onlineEvaluationConfigId: c.onlineEvaluationConfigId ?? '', + onlineEvaluationConfigArn: c.onlineEvaluationConfigArn ?? '', + onlineEvaluationConfigName: c.onlineEvaluationConfigName ?? '', + description: c.description, + status: c.status ?? 'UNKNOWN', + executionStatus: c.executionStatus ?? 'UNKNOWN', + })), + nextToken: response.nextToken, + }; +} + +/** + * List all online evaluation configs in the given region, paginating through all pages. + */ +export async function listAllOnlineEvaluationConfigs(options: { region: string }): Promise { + return paginateAll(options.region, async (opts, client) => { + const result = await listOnlineEvaluationConfigs(opts, client); + return { items: result.configs, nextToken: result.nextToken }; + }); +} + +// ============================================================================ +// Online Eval Config — Update / Get // ============================================================================ export type OnlineEvalExecutionStatus = 'ENABLED' | 'DISABLED'; @@ -172,10 +702,7 @@ export async function updateOnlineEvalExecutionStatus( * Update an online evaluation config with any supported fields. */ export async function updateOnlineEvalConfig(options: UpdateOnlineEvalOptions): Promise { - const client = new BedrockAgentCoreControlClient({ - region: options.region, - credentials: getCredentialProvider(), - }); + const client = createControlClient(options.region); const command = new UpdateOnlineEvaluationConfigCommand({ onlineEvaluationConfigId: options.onlineEvaluationConfigId, @@ -205,15 +732,18 @@ export interface GetOnlineEvalConfigResult { description?: string; failureReason?: string; outputLogGroupName?: string; + /** Sampling percentage from the rule config */ + samplingPercentage?: number; + /** Service names from CloudWatch data source config (e.g. "projectName_agentName.DEFAULT") */ + serviceNames?: string[]; + /** Evaluator IDs referenced by this config */ + evaluatorIds?: string[]; } export async function getOnlineEvaluationConfig( options: GetOnlineEvalConfigOptions ): Promise { - const client = new BedrockAgentCoreControlClient({ - region: options.region, - credentials: getCredentialProvider(), - }); + const client = createControlClient(options.region); const command = new GetOnlineEvaluationConfigCommand({ onlineEvaluationConfigId: options.configId, @@ -226,6 +756,14 @@ export async function getOnlineEvaluationConfig( } const logGroupName = response.outputConfig?.cloudWatchConfig?.logGroupName; + const samplingPercentage = response.rule?.samplingConfig?.samplingPercentage; + const serviceNames = + response.dataSourceConfig && 'cloudWatchLogs' in response.dataSourceConfig + ? response.dataSourceConfig.cloudWatchLogs?.serviceNames + : undefined; + const evaluatorIds = (response.evaluators ?? []) + .map(e => ('evaluatorId' in e ? e.evaluatorId : undefined)) + .filter((id): id is string => !!id); return { configId: response.onlineEvaluationConfigId, @@ -236,5 +774,8 @@ export async function getOnlineEvaluationConfig( description: response.description, failureReason: response.failureReason, outputLogGroupName: logGroupName, + samplingPercentage, + serviceNames, + evaluatorIds, }; } diff --git a/src/cli/aws/agentcore-recommendation.ts b/src/cli/aws/agentcore-recommendation.ts index 82cc1e8a9..61fd223a7 100644 --- a/src/cli/aws/agentcore-recommendation.ts +++ b/src/cli/aws/agentcore-recommendation.ts @@ -277,7 +277,7 @@ async function signedRequest(options: { if (!response.ok) { const errorBody = await response.text(); - throw new Error(`Recommendation API error (${response.status}): ${errorBody}\n[RequestId: ${requestId}]`); + throw new Error(`Recommendation API error (${response.status}): ${errorBody}`); } if (response.status === 204) return { data: {}, status: 204, requestId }; diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 6f98a8fae..1820f0a7f 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -4,6 +4,7 @@ import { BedrockAgentCoreClient, EvaluateCommand, InvokeAgentRuntimeCommand, + InvokeAgentRuntimeCommandCommand, StopRuntimeSessionCommand, } from '@aws-sdk/client-bedrock-agentcore'; import type { HttpRequest } from '@smithy/protocol-http'; @@ -333,9 +334,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt buffer += decoded; fullResponse += decoded; - // Process complete lines from the buffer const lines = buffer.split('\n'); - // Keep the last incomplete line in the buffer buffer = lines.pop() ?? ''; for (const line of lines) { @@ -831,8 +830,9 @@ export async function invokeA2ARuntime(options: A2AInvokeOptions, message: strin } /** Wrap a single string value as an AsyncGenerator for StreamingInvokeResult compatibility. */ +// eslint-disable-next-line @typescript-eslint/require-await async function* singleValueStream(value: string): AsyncGenerator { - yield await Promise.resolve(value); + yield value; } /** Extract text content from A2A JSON-RPC response. Supports both kind:'text' and type:'text' part formats. */ @@ -911,3 +911,89 @@ export async function stopRuntimeSession(options: StopRuntimeSessionOptions): Pr statusCode: response.statusCode, }; } + +// --------------------------------------------------------------------------- +// Execute Bash: Run shell commands in runtime containers +// --------------------------------------------------------------------------- + +export interface ExecuteBashOptions { + region: string; + runtimeArn: string; + command: string; + sessionId?: string; + timeout?: number; + /** Custom headers to forward to the agent runtime */ + headers?: Record; + /** Bearer token for CUSTOM_JWT auth — not yet supported for exec, will throw */ + bearerToken?: string; +} + +export interface ExecuteBashStreamEvent { + type: 'start' | 'stdout' | 'stderr' | 'stop'; + data?: string; + exitCode?: number; + status?: string; +} + +export interface ExecuteBashResult { + stream: AsyncGenerator; + sessionId: string | undefined; +} + +/** + * Execute a shell command in a running AgentCore Runtime container. + * Returns a streaming result with stdout/stderr events and exit code. + */ +export async function executeBashCommand(options: ExecuteBashOptions): Promise { + if (options.bearerToken) { + throw new Error('Bearer token auth for exec is not yet supported. Use SigV4 credentials.'); + } + + const client = createAgentCoreClient(options.region, options.headers); + + const command = new InvokeAgentRuntimeCommandCommand({ + agentRuntimeArn: options.runtimeArn, + runtimeSessionId: options.sessionId, + body: { + command: options.command, + ...(options.timeout != null ? { timeout: options.timeout } : {}), + }, + }); + + const response = await client.send(command); + const sessionId = response.runtimeSessionId; + + async function* streamEvents(): AsyncGenerator { + if (!response.stream) { + throw new Error('No stream in response from AgentCore Runtime'); + } + for await (const event of response.stream) { + // SDK types for InvokeAgentRuntimeCommandCommand stream events are not yet published — cast needed until SDK stabilizes + const chunk = (event as unknown as Record).chunk as Record | undefined; + if (!chunk || typeof chunk !== 'object') continue; + + if (chunk.contentStart !== undefined) { + yield { type: 'start' }; + } + const delta = chunk.contentDelta as { stdout?: string; stderr?: string } | undefined; + if (delta) { + if (delta.stdout) { + yield { type: 'stdout', data: delta.stdout }; + } + if (delta.stderr) { + yield { type: 'stderr', data: delta.stderr }; + } + } + const stop = chunk.contentStop as { exitCode?: number; status?: string } | undefined; + if (stop) { + yield { + type: 'stop', + exitCode: stop.exitCode, + status: stop.status, + }; + } + } + } + + return { stream: streamEvents(), sessionId }; +} diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 57077e97a..8557045d6 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -9,6 +9,7 @@ export { type ClaudeResponse, } from './bedrock'; export { + createControlClient, getAgentRuntimeStatus, type AgentRuntimeStatusResult, type GetAgentRuntimeStatusOptions, @@ -25,6 +26,7 @@ export { } from './policy-generation'; export { DEFAULT_RUNTIME_USER_ID, + executeBashCommand, invokeA2ARuntime, invokeAgentRuntime, invokeAgentRuntimeStreaming, @@ -32,6 +34,9 @@ export { mcpListTools, mcpCallTool, stopRuntimeSession, + type ExecuteBashOptions, + type ExecuteBashResult, + type ExecuteBashStreamEvent, type InvokeAgentRuntimeOptions, type InvokeAgentRuntimeResult, type McpInvokeOptions, diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index b1d17f47b..d95316b7f 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -34,12 +34,17 @@ export interface CreateProjectOptions { name: string; cwd: string; skipGit?: boolean; + skipInstall?: boolean; skipDependencyCheck?: boolean; onProgress?: ProgressCallback; } export async function createProject(options: CreateProjectOptions): Promise { - const { name, cwd, skipGit, skipDependencyCheck, onProgress } = options; + const { name, cwd, skipGit, skipInstall, skipDependencyCheck, onProgress } = options; + + if (skipInstall) { + process.env.AGENTCORE_SKIP_INSTALL = '1'; + } const projectRoot = join(cwd, name); const configBaseDir = join(projectRoot, CONFIG_DIR); @@ -125,6 +130,7 @@ export interface CreateWithAgentOptions { idleTimeout?: number; maxLifetime?: number; skipGit?: boolean; + skipInstall?: boolean; skipPythonSetup?: boolean; onProgress?: ProgressCallback; } @@ -147,6 +153,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P idleTimeout, maxLifetime: maxLifetimeOpt, skipGit, + skipInstall, skipPythonSetup, onProgress, } = options; @@ -163,7 +170,7 @@ 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, skipDependencyCheck: true, onProgress }); + const projectResult = await createProject({ name, cwd, skipGit, skipInstall, skipDependencyCheck: true, onProgress }); if (!projectResult.success) { // Merge warnings from both checks const allWarnings = [...depWarnings, ...(projectResult.warnings ?? [])]; @@ -267,7 +274,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P onProgress?.('Add agent to project', 'done'); // Set up Python environment if needed (unless skipped) - if (language === 'Python' && !skipPythonSetup) { + if (language === 'Python' && !skipPythonSetup && !skipInstall) { onProgress?.('Set up Python environment', 'start'); const agentDir = join(projectRoot, APP_DIR, name); await setupPythonProject({ projectDir: agentDir }); diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 7f4b5a1e3..7d7c3ff75 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -119,7 +119,13 @@ async function handleCreateCLI(options: CreateOptions): Promise { const skipAgent = options.agent === false; const result = skipAgent - ? await createProject({ name: options.name!, cwd, skipGit: options.skipGit, onProgress }) + ? await createProject({ + name: options.name!, + cwd, + skipGit: options.skipGit, + skipInstall: options.skipInstall, + onProgress, + }) : await createProjectWithAgent({ name: options.name!, cwd, @@ -140,6 +146,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, skipGit: options.skipGit, + skipInstall: options.skipInstall, skipPythonSetup: options.skipPythonSetup, onProgress, }); @@ -148,6 +155,11 @@ async function handleCreateCLI(options: CreateOptions): Promise { console.log(JSON.stringify(result)); } else if (result.success) { printCreateSummary(options.name!, 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); } @@ -190,6 +202,7 @@ export const registerCreate = (program: Command) => { .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]') + .option('--skip-install', 'Skip all dependency installation (npm install, uv sync) [non-interactive]') .option('--dry-run', 'Preview what would be created without making changes [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async options => { @@ -217,6 +230,7 @@ export const registerCreate = (program: Command) => { options.outputDir ?? options.skipGit ?? options.skipPythonSetup ?? + options.skipInstall ?? options.dryRun ?? options.json ); diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index fd9fb13a4..eee545609 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -20,6 +20,7 @@ export interface CreateOptions extends VpcOptions { outputDir?: string; skipGit?: boolean; skipPythonSetup?: boolean; + skipInstall?: boolean; dryRun?: boolean; json?: boolean; } diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index ca93eba96..a501a2563 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -1,5 +1,6 @@ import { findConfigRoot, getWorkingDirectory, readEnvFile } from '../../../lib'; import { getErrorMessage } from '../../errors'; +import { detectContainerRuntime } from '../../external-requirements'; import { ExecLogger } from '../../logging'; import { callMcpTool, @@ -22,6 +23,7 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import { parseHeaderFlags } from '../shared/header-utils'; import type { Command } from '@commander-js/extra-typings'; +import { spawn } from 'child_process'; import { Text, render } from 'ink'; import React from 'react'; @@ -140,6 +142,26 @@ async function handleMcpInvoke( } } +async function execInContainer(command: string, containerName: string): Promise { + const detection = await detectContainerRuntime(); + if (!detection.runtime) { + console.error('Error: No container runtime found (docker, podman, or finch required)'); + process.exit(1); + } + return new Promise((resolve, reject) => { + const child = spawn(detection.runtime!.binary, ['exec', containerName, 'bash', '-c', command], { + stdio: 'inherit', + }); + child.on('error', reject); + child.on('close', code => { + if (code !== 0 && code !== null) { + process.exit(code); + } + resolve(); + }); + }); +} + export const registerDev = (program: Command) => { program .command('dev') @@ -150,6 +172,7 @@ export const registerDev = (program: Command) => { .option('-r, --runtime ', 'Runtime to run or invoke (required if multiple runtimes)') .option('-s, --stream', 'Stream response when invoking [non-interactive]') .option('-l, --logs', 'Run dev server with logs to stdout [non-interactive]') + .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( @@ -168,6 +191,26 @@ export const registerDev = (program: Command) => { headers = parseHeaderFlags(opts.header); } + // Exec mode: run shell command in the dev container + if (opts.exec) { + if (!positionalPrompt) { + console.error('A command is required with --exec. Usage: agentcore dev --exec "whoami"'); + process.exit(1); + } + const workingDir = getWorkingDirectory(); + const project = await loadProjectConfig(workingDir); + const agentName = opts.runtime ?? project?.runtimes[0]?.name ?? 'unknown'; + const targetAgent = project?.runtimes.find(a => a.name === agentName); + if (targetAgent?.build !== 'Container') { + console.error('Error: --exec is only supported for Container build agents.'); + console.error('For CodeZip agents, use your terminal to run commands directly.'); + process.exit(1); + } + const containerName = `agentcore-dev-${agentName}`.toLowerCase(); + await execInContainer(positionalPrompt, containerName); + return; + } + // If a prompt is provided, invoke a running dev server const invokePrompt = positionalPrompt; if (invokePrompt !== undefined) { diff --git a/src/cli/commands/fetch/__tests__/fetch-access.test.ts b/src/cli/commands/fetch/__tests__/fetch-access.test.ts index 92f2d0081..76f5e5557 100644 --- a/src/cli/commands/fetch/__tests__/fetch-access.test.ts +++ b/src/cli/commands/fetch/__tests__/fetch-access.test.ts @@ -173,4 +173,24 @@ describe('registerFetch', () => { const renderArg = mockRender.mock.calls[0]![0]; expect(JSON.stringify(renderArg)).toContain('Token fetch failed'); }); + + it('accepts --identity-name option and passes it through to fetchGatewayToken', async () => { + mockFetchGatewayToken.mockResolvedValue(jwtResult); + + await program.parseAsync( + ['fetch', 'access', '--name', 'myGateway', '--identity-name', 'my-custom-cred', '--json'], + { + from: 'user', + } + ); + + expect(mockFetchGatewayToken).toHaveBeenCalledWith( + 'myGateway', + expect.objectContaining({ identityName: 'my-custom-cred' }) + ); + + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0][0]); + expect(output.success).toBe(true); + }); }); diff --git a/src/cli/commands/fetch/action.ts b/src/cli/commands/fetch/action.ts index 605428b0f..c8bd44091 100644 --- a/src/cli/commands/fetch/action.ts +++ b/src/cli/commands/fetch/action.ts @@ -32,7 +32,10 @@ async function handleFetchGatewayAccess(options: FetchAccessOptions): Promise { .option('--name ', 'Gateway or agent name [non-interactive]') .option('--type ', 'Resource type: gateway (default) or agent [non-interactive]', 'gateway') .option('--target ', 'Deployment target [non-interactive]') + .option('--identity-name ', 'Identity credential name for token fetch [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async (cliOptions: Record) => { const options = cliOptions as unknown as FetchAccessOptions; diff --git a/src/cli/commands/fetch/types.ts b/src/cli/commands/fetch/types.ts index 2d4ffa36f..43b12d9cc 100644 --- a/src/cli/commands/fetch/types.ts +++ b/src/cli/commands/fetch/types.ts @@ -4,5 +4,6 @@ export interface FetchAccessOptions { name?: string; type?: FetchResourceType; target?: string; + identityName?: string; json?: boolean; } diff --git a/src/cli/commands/import/__tests__/import-evaluator.test.ts b/src/cli/commands/import/__tests__/import-evaluator.test.ts new file mode 100644 index 000000000..5e6fb5e96 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-evaluator.test.ts @@ -0,0 +1,437 @@ +/** + * Import Evaluator Unit Tests + * + * Covers: + * - toEvaluatorSpec conversion: LLM-as-a-Judge (numerical + categorical), code-based (external) + * - Evaluator with description and tags + * - Missing config error handling + * - Template logical ID lookup for evaluators + * - Phase 2 import resource list construction for evaluators + * - ARN validation for evaluator resource type + */ +import type { GetEvaluatorResult } from '../../../aws/agentcore-control'; +import { toEvaluatorSpec } from '../import-evaluator'; +import { buildImportTemplate, findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import type { CfnTemplate } from '../template-utils'; +import type { ResourceToImport } from '../types'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// toEvaluatorSpec Conversion Tests +// ============================================================================ + +describe('toEvaluatorSpec', () => { + it('maps LLM-as-a-Judge evaluator with numerical rating scale', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-123', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-123', + evaluatorName: 'my_evaluator', + level: 'SESSION', + status: 'ACTIVE', + description: 'Test evaluator', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate the response quality', + ratingScale: { + numerical: [ + { value: 1, label: 'Poor', definition: 'Low quality response' }, + { value: 5, label: 'Excellent', definition: 'High quality response' }, + ], + }, + }, + }, + tags: { env: 'test' }, + }; + + const result = toEvaluatorSpec(detail, 'my_evaluator'); + + expect(result.name).toBe('my_evaluator'); + expect(result.level).toBe('SESSION'); + expect(result.description).toBe('Test evaluator'); + expect(result.config.llmAsAJudge).toBeDefined(); + expect(result.config.llmAsAJudge!.model).toBe('anthropic.claude-3-5-sonnet-20241022-v2:0'); + expect(result.config.llmAsAJudge!.instructions).toBe('Evaluate the response quality'); + expect(result.config.llmAsAJudge!.ratingScale.numerical).toHaveLength(2); + expect(result.config.llmAsAJudge!.ratingScale.numerical![0]).toEqual({ + value: 1, + label: 'Poor', + definition: 'Low quality response', + }); + expect(result.tags).toEqual({ env: 'test' }); + }); + + it('maps LLM-as-a-Judge evaluator with categorical rating scale', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-456', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-456', + evaluatorName: 'categorical_eval', + level: 'TRACE', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Rate as pass or fail', + ratingScale: { + categorical: [ + { label: 'Pass', definition: 'Response meets criteria' }, + { label: 'Fail', definition: 'Response does not meet criteria' }, + ], + }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'categorical_eval'); + + expect(result.level).toBe('TRACE'); + expect(result.config.llmAsAJudge).toBeDefined(); + expect(result.config.llmAsAJudge!.ratingScale.categorical).toHaveLength(2); + expect(result.config.llmAsAJudge!.ratingScale.categorical![0]).toEqual({ + label: 'Pass', + definition: 'Response meets criteria', + }); + // No description or tags + expect(result.description).toBeUndefined(); + expect(result.tags).toBeUndefined(); + }); + + it('maps code-based evaluator as external with Lambda ARN', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-code-789', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-code-789', + evaluatorName: 'code_eval', + level: 'TOOL_CALL', + status: 'ACTIVE', + evaluatorConfig: { + codeBased: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-eval-function', + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'code_eval'); + + expect(result.name).toBe('code_eval'); + expect(result.level).toBe('TOOL_CALL'); + expect(result.config.codeBased).toBeDefined(); + expect(result.config.codeBased!.external).toBeDefined(); + expect(result.config.codeBased!.external!.lambdaArn).toBe( + 'arn:aws:lambda:us-west-2:123456789012:function:my-eval-function' + ); + expect(result.config.llmAsAJudge).toBeUndefined(); + }); + + it('uses provided local name instead of evaluator name from AWS', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-rename', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-rename', + evaluatorName: 'original_name', + 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, 'custom_local_name'); + + expect(result.name).toBe('custom_local_name'); + }); + + it('throws when evaluator has no recognizable config', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-config', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-config', + evaluatorName: 'broken_eval', + level: 'SESSION', + status: 'ACTIVE', + }; + + expect(() => toEvaluatorSpec(detail, 'broken_eval')).toThrow('Evaluator "broken_eval" has no recognizable config'); + }); + + it('throws when evaluatorConfig is empty object', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-empty', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-empty', + evaluatorName: 'empty_config_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: {}, + }; + + expect(() => toEvaluatorSpec(detail, 'empty_config_eval')).toThrow('has no recognizable config'); + }); + + it('omits description when not present', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-desc', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-desc', + evaluatorName: 'no_desc_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_desc_eval'); + + expect(result.description).toBeUndefined(); + }); + + it('omits tags when empty', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-empty-tags', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-empty-tags', + evaluatorName: 'empty_tags_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' }] }, + }, + }, + tags: {}, + }; + + const result = toEvaluatorSpec(detail, 'empty_tags_eval'); + + expect(result.tags).toBeUndefined(); + }); +}); + +// ============================================================================ +// Template Logical ID Lookup Tests for Evaluators +// ============================================================================ + +describe('Template Logical ID Lookup for Evaluators', () => { + const synthTemplate: CfnTemplate = { + Resources: { + MyEvaluatorResource: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + }, + PrefixedEvaluatorResource: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'TestProject_prefixed_eval', + Level: 'TRACE', + }, + }, + MyAgentRuntime: { + Type: 'AWS::BedrockAgentCore::Runtime', + Properties: { + AgentRuntimeName: 'TestProject_my_agent', + }, + }, + MyIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: 'MyRole', + }, + }, + }, + }; + + it('finds evaluator logical ID by EvaluatorName property', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'my_evaluator' + ); + expect(logicalId).toBe('MyEvaluatorResource'); + }); + + it('finds prefixed evaluator by full name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'TestProject_prefixed_eval' + ); + expect(logicalId).toBe('PrefixedEvaluatorResource'); + }); + + it('finds all evaluator logical IDs by type', () => { + const logicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Evaluator'); + expect(logicalIds).toHaveLength(2); + expect(logicalIds).toContain('MyEvaluatorResource'); + expect(logicalIds).toContain('PrefixedEvaluatorResource'); + }); + + it('returns undefined for non-existent evaluator name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'nonexistent_evaluator' + ); + expect(logicalId).toBeUndefined(); + }); + + it('falls back to single evaluator logical ID when name does not match', () => { + const singleEvalTemplate: CfnTemplate = { + Resources: { + OnlyEvaluator: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'some_eval', + Level: 'SESSION', + }, + }, + }, + }; + + let logicalId = findLogicalIdByProperty( + singleEvalTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'different_name' + ); + + // Primary lookup fails + expect(logicalId).toBeUndefined(); + + // Fallback: if there's only one evaluator resource, use it + if (!logicalId) { + const evaluatorLogicalIds = findLogicalIdsByType(singleEvalTemplate, 'AWS::BedrockAgentCore::Evaluator'); + if (evaluatorLogicalIds.length === 1) { + logicalId = evaluatorLogicalIds[0]; + } + } + expect(logicalId).toBe('OnlyEvaluator'); + }); +}); + +// ============================================================================ +// Phase 2 Resource Import List Construction for Evaluators +// ============================================================================ + +describe('Phase 2: ResourceToImport List Construction for Evaluators', () => { + const synthTemplate: CfnTemplate = { + Resources: { + EvaluatorLogicalId: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + }, + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + it('builds ResourceToImport list for evaluator', () => { + const evaluatorName = 'my_evaluator'; + const evaluatorId = 'eval-123'; + + const resourcesToImport: ResourceToImport[] = []; + + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + evaluatorName + ); + + if (logicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Evaluator', + logicalResourceId: logicalId, + resourceIdentifier: { EvaluatorId: evaluatorId }, + }); + } + + expect(resourcesToImport).toHaveLength(1); + expect(resourcesToImport[0]!.resourceType).toBe('AWS::BedrockAgentCore::Evaluator'); + expect(resourcesToImport[0]!.logicalResourceId).toBe('EvaluatorLogicalId'); + expect(resourcesToImport[0]!.resourceIdentifier).toEqual({ EvaluatorId: 'eval-123' }); + }); + + it('returns empty list when evaluator not found in template', () => { + const emptyTemplate: CfnTemplate = { + Resources: { + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + const logicalId = findLogicalIdByProperty( + emptyTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'my_evaluator' + ); + + expect(logicalId).toBeUndefined(); + }); +}); + +// ============================================================================ +// buildImportTemplate Tests for Evaluator Resources +// ============================================================================ + +describe('buildImportTemplate with Evaluator', () => { + it('adds evaluator resource to deployed template with Retain deletion policy', () => { + const deployedTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + }, + }; + + const synthTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + EvaluatorLogicalId: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + DependsOn: 'ExistingIAMRole', + }, + }, + }; + + const importTemplate = buildImportTemplate(deployedTemplate, synthTemplate, ['EvaluatorLogicalId']); + + // Verify evaluator resource was added + expect(importTemplate.Resources.EvaluatorLogicalId).toBeDefined(); + expect(importTemplate.Resources.EvaluatorLogicalId!.Type).toBe('AWS::BedrockAgentCore::Evaluator'); + expect(importTemplate.Resources.EvaluatorLogicalId!.DeletionPolicy).toBe('Retain'); + expect(importTemplate.Resources.EvaluatorLogicalId!.UpdateReplacePolicy).toBe('Retain'); + + // DependsOn should be removed for import + expect(importTemplate.Resources.EvaluatorLogicalId!.DependsOn).toBeUndefined(); + + // Original resource should still be there + expect(importTemplate.Resources.ExistingIAMRole).toBeDefined(); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-online-eval.test.ts b/src/cli/commands/import/__tests__/import-online-eval.test.ts new file mode 100644 index 000000000..57f0137fc --- /dev/null +++ b/src/cli/commands/import/__tests__/import-online-eval.test.ts @@ -0,0 +1,368 @@ +/** + * Import Online Eval Config Unit Tests + * + * Covers: + * - extractAgentName: service name parsing + * - toOnlineEvalConfigSpec conversion: happy path, missing sampling, enableOnCreate + * - Template logical ID lookup for online eval configs + * - Phase 2 import resource list construction for online eval configs + */ +import type { GetOnlineEvalConfigResult } from '../../../aws/agentcore-control'; +import { extractAgentName, toOnlineEvalConfigSpec } from '../import-online-eval'; +import { buildImportTemplate, findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import type { CfnTemplate } from '../template-utils'; +import type { ResourceToImport } from '../types'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// extractAgentName Tests +// ============================================================================ + +describe('extractAgentName', () => { + it('extracts agent name from service name with .DEFAULT suffix', () => { + expect(extractAgentName(['my_agent.DEFAULT'])).toBe('my_agent'); + }); + + it('extracts agent name with project prefix pattern', () => { + expect(extractAgentName(['testproject_my_agent.DEFAULT'])).toBe('testproject_my_agent'); + }); + + it('returns full string when no dot suffix', () => { + expect(extractAgentName(['my_agent'])).toBe('my_agent'); + }); + + it('returns undefined for empty array', () => { + expect(extractAgentName([])).toBeUndefined(); + }); + + it('uses first service name when multiple provided', () => { + expect(extractAgentName(['agent_one.DEFAULT', 'agent_two.DEFAULT'])).toBe('agent_one'); + }); + + it('handles service name with multiple dots', () => { + expect(extractAgentName(['my.agent.DEFAULT'])).toBe('my.agent'); + }); +}); + +// ============================================================================ +// toOnlineEvalConfigSpec Conversion Tests +// ============================================================================ + +describe('toOnlineEvalConfigSpec', () => { + it('maps online eval config with all fields', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-123', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-123', + configName: 'QualityMonitor', + status: 'ACTIVE', + executionStatus: 'ENABLED', + description: 'Monitor agent quality', + samplingPercentage: 50, + serviceNames: ['my_agent.DEFAULT'], + evaluatorIds: ['eval-456'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'QualityMonitor', 'my_agent', ['my_evaluator']); + + expect(result.name).toBe('QualityMonitor'); + expect(result.agent).toBe('my_agent'); + expect(result.evaluators).toEqual(['my_evaluator']); + expect(result.samplingRate).toBe(50); + expect(result.description).toBe('Monitor agent quality'); + expect(result.enableOnCreate).toBe(true); + }); + + it('omits enableOnCreate when execution status is DISABLED', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-456', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-456', + configName: 'DisabledConfig', + status: 'ACTIVE', + executionStatus: 'DISABLED', + samplingPercentage: 10, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'DisabledConfig', 'agent', ['eval_one']); + + expect(result.enableOnCreate).toBeUndefined(); + }); + + it('omits description when not present', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-789', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-789', + configName: 'NoDesc', + status: 'ACTIVE', + executionStatus: 'ENABLED', + samplingPercentage: 25, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'NoDesc', 'agent', ['eval_one']); + + expect(result.description).toBeUndefined(); + }); + + it('throws when sampling percentage is missing', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-no-sampling', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-no-sampling', + configName: 'NoSampling', + status: 'ACTIVE', + executionStatus: 'ENABLED', + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + expect(() => toOnlineEvalConfigSpec(detail, 'NoSampling', 'agent', ['eval_one'])).toThrow( + 'has no sampling configuration' + ); + }); + + it('supports multiple evaluator references', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-multi', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-multi', + configName: 'MultiEval', + status: 'ACTIVE', + executionStatus: 'ENABLED', + samplingPercentage: 75, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1', 'eval-2'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'MultiEval', 'agent', [ + 'local_eval', + 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-2', + ]); + + expect(result.evaluators).toHaveLength(2); + expect(result.evaluators[0]).toBe('local_eval'); + expect(result.evaluators[1]).toMatch(/^arn:/); + }); +}); + +// ============================================================================ +// Template Logical ID Lookup Tests for Online Eval Configs +// ============================================================================ + +describe('Template Logical ID Lookup for Online Eval Configs', () => { + const synthTemplate: CfnTemplate = { + Resources: { + MyOnlineEvalConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + }, + PrefixedOnlineEvalConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'TestProject_PrefixedConfig', + }, + }, + MyAgentRuntime: { + Type: 'AWS::BedrockAgentCore::Runtime', + Properties: { + AgentRuntimeName: 'TestProject_my_agent', + }, + }, + MyIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: 'MyRole', + }, + }, + }, + }; + + it('finds online eval config logical ID by OnlineEvaluationConfigName property', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'QualityMonitor' + ); + expect(logicalId).toBe('MyOnlineEvalConfig'); + }); + + it('finds prefixed online eval config by full name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'TestProject_PrefixedConfig' + ); + expect(logicalId).toBe('PrefixedOnlineEvalConfig'); + }); + + it('finds all online eval config logical IDs by type', () => { + const logicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(logicalIds).toHaveLength(2); + expect(logicalIds).toContain('MyOnlineEvalConfig'); + expect(logicalIds).toContain('PrefixedOnlineEvalConfig'); + }); + + it('returns undefined for non-existent config name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'nonexistent_config' + ); + expect(logicalId).toBeUndefined(); + }); + + it('falls back to single online eval config logical ID when name does not match', () => { + const singleConfigTemplate: CfnTemplate = { + Resources: { + OnlyConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'some_config', + }, + }, + }, + }; + + let logicalId = findLogicalIdByProperty( + singleConfigTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'different_name' + ); + + // Primary lookup fails + expect(logicalId).toBeUndefined(); + + // Fallback: if there's only one config resource, use it + if (!logicalId) { + const configLogicalIds = findLogicalIdsByType( + singleConfigTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig' + ); + if (configLogicalIds.length === 1) { + logicalId = configLogicalIds[0]; + } + } + expect(logicalId).toBe('OnlyConfig'); + }); +}); + +// ============================================================================ +// Phase 2 Resource Import List Construction for Online Eval Configs +// ============================================================================ + +describe('Phase 2: ResourceToImport List Construction for Online Eval Configs', () => { + const synthTemplate: CfnTemplate = { + Resources: { + OnlineEvalLogicalId: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + }, + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + it('builds ResourceToImport list for online eval config', () => { + const configName = 'QualityMonitor'; + const configId = 'oec-123'; + + const resourcesToImport: ResourceToImport[] = []; + + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + configName + ); + + if (logicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + logicalResourceId: logicalId, + resourceIdentifier: { OnlineEvaluationConfigId: configId }, + }); + } + + expect(resourcesToImport).toHaveLength(1); + expect(resourcesToImport[0]!.resourceType).toBe('AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(resourcesToImport[0]!.logicalResourceId).toBe('OnlineEvalLogicalId'); + expect(resourcesToImport[0]!.resourceIdentifier).toEqual({ OnlineEvaluationConfigId: 'oec-123' }); + }); + + it('returns empty list when online eval config not found in template', () => { + const emptyTemplate: CfnTemplate = { + Resources: { + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + const logicalId = findLogicalIdByProperty( + emptyTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'QualityMonitor' + ); + + expect(logicalId).toBeUndefined(); + }); +}); + +// ============================================================================ +// buildImportTemplate Tests for Online Eval Config Resources +// ============================================================================ + +describe('buildImportTemplate with Online Eval Config', () => { + it('adds online eval config resource to deployed template with Retain deletion policy', () => { + const deployedTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + }, + }; + + const synthTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + OnlineEvalLogicalId: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + DependsOn: 'ExistingIAMRole', + }, + }, + }; + + const importTemplate = buildImportTemplate(deployedTemplate, synthTemplate, ['OnlineEvalLogicalId']); + + // Verify online eval config resource was added + expect(importTemplate.Resources.OnlineEvalLogicalId).toBeDefined(); + expect(importTemplate.Resources.OnlineEvalLogicalId!.Type).toBe('AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(importTemplate.Resources.OnlineEvalLogicalId!.DeletionPolicy).toBe('Retain'); + expect(importTemplate.Resources.OnlineEvalLogicalId!.UpdateReplacePolicy).toBe('Retain'); + + // DependsOn should be removed for import + expect(importTemplate.Resources.OnlineEvalLogicalId!.DependsOn).toBeUndefined(); + + // Original resource should still be there + expect(importTemplate.Resources.ExistingIAMRole).toBeDefined(); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-runtime-entrypoint.test.ts b/src/cli/commands/import/__tests__/import-runtime-entrypoint.test.ts new file mode 100644 index 000000000..bc33905f3 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-runtime-entrypoint.test.ts @@ -0,0 +1,60 @@ +/** + * Tests for import runtime entrypoint detection and --entrypoint flag. + * + * Covers: + * - extractEntrypoint: auto-detection from API entryPoint array + * - --entrypoint flag override via handleImportRuntime + * - Failure when entrypoint cannot be detected and no flag provided + */ +import { extractEntrypoint } from '../import-runtime'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// extractEntrypoint — unit tests +// ============================================================================ + +describe('extractEntrypoint', () => { + it('returns undefined for undefined input', () => { + expect(extractEntrypoint(undefined)).toBeUndefined(); + }); + + it('returns undefined for empty array', () => { + expect(extractEntrypoint([])).toBeUndefined(); + }); + + it('extracts .py file from otel wrapper array', () => { + expect(extractEntrypoint(['opentelemetry-instrument', 'main.py'])).toBe('main.py'); + }); + + it('extracts .py file when it is the only element', () => { + expect(extractEntrypoint(['main.py'])).toBe('main.py'); + }); + + it('returns undefined when only non-file entries exist', () => { + expect(extractEntrypoint(['opentelemetry-instrument'])).toBeUndefined(); + }); + + it('returns undefined for entries without known extensions', () => { + expect(extractEntrypoint(['gunicorn', 'flask-app'])).toBeUndefined(); + }); + + it('extracts .ts file', () => { + expect(extractEntrypoint(['handler.ts'])).toBe('handler.ts'); + }); + + it('extracts .js file', () => { + expect(extractEntrypoint(['index.js'])).toBe('index.js'); + }); + + it('picks the first matching file when multiple exist', () => { + expect(extractEntrypoint(['wrapper', 'app.py', 'fallback.py'])).toBe('app.py'); + }); + + it('extracts file with path prefix', () => { + expect(extractEntrypoint(['opentelemetry-instrument', 'src/main.py'])).toBe('src/main.py'); + }); + + it('returns undefined for extensionless entries', () => { + expect(extractEntrypoint(['python', '-m', 'myapp'])).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts new file mode 100644 index 000000000..b4fb7de90 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts @@ -0,0 +1,612 @@ +/** + * Tests for handleImportRuntime — focused on entrypoint resolution, + * input validation, and error handling. + * + * Covers: + * - Fails with clear error when entrypoint is undetectable and no --entrypoint flag + * - Uses --entrypoint flag when provided + * - Fails when --code is not provided + * - Fails when source path does not exist + * - Fails when runtime name already exists in project + */ +import { handleImportRuntime } from '../import-runtime'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// ── Mock dependencies ──────────────────────────────────────────────────────── + +const mockResolveProjectContext = vi.fn(); +const mockResolveImportTarget = vi.fn(); +const mockResolveImportContext = vi.fn(); +const mockUpdateDeployedState = vi.fn(); +const mockCopyAgentSource = vi.fn(); +const mockToStackName = vi.fn(); + +const mockParseAndValidateArn = vi.fn(); +const mockFindResourceInDeployedState = vi.fn(); +const mockFailResult = vi.fn((...args: unknown[]) => ({ + success: false, + error: args[1] as string, + resourceType: args[2] as string, + resourceName: args[3] as string, + logPath: 'test.log', +})); + +vi.mock('../import-utils', () => ({ + resolveProjectContext: (...args: unknown[]) => mockResolveProjectContext(...args), + resolveImportTarget: (...args: unknown[]) => mockResolveImportTarget(...args), + resolveImportContext: (...args: unknown[]) => mockResolveImportContext(...args), + updateDeployedState: (...args: unknown[]) => mockUpdateDeployedState(...args), + copyAgentSource: (...args: unknown[]) => mockCopyAgentSource(...args), + toStackName: (...args: unknown[]) => mockToStackName(...args), + parseAndValidateArn: (...args: unknown[]) => mockParseAndValidateArn(...args), + findResourceInDeployedState: (...args: unknown[]) => mockFindResourceInDeployedState(...args), + failResult: (...args: unknown[]) => mockFailResult(...args), +})); + +const mockExecuteCdkImportPipeline = vi.fn(); + +vi.mock('../import-pipeline', () => ({ + executeCdkImportPipeline: (...args: unknown[]) => mockExecuteCdkImportPipeline(...args), +})); + +const mockGetAgentRuntimeDetail = vi.fn(); +const mockListAllAgentRuntimes = vi.fn(); + +vi.mock('../../../aws/agentcore-control', () => ({ + getAgentRuntimeDetail: (...args: unknown[]) => mockGetAgentRuntimeDetail(...args), + listAllAgentRuntimes: (...args: unknown[]) => mockListAllAgentRuntimes(...args), +})); + +vi.mock('../../../logging', () => { + const MockExecLogger = vi.fn(function (this: Record) { + this.startStep = vi.fn(); + this.endStep = vi.fn(); + this.log = vi.fn(); + this.finalize = vi.fn(); + this.getRelativeLogPath = vi.fn().mockReturnValue('test.log'); + }); + return { ExecLogger: MockExecLogger }; +}); + +vi.mock('../../../cdk/local-cdk-project', () => ({ + LocalCdkProject: vi.fn(), +})); + +vi.mock('../../../cdk/toolkit-lib', () => ({ + silentIoHost: {}, +})); + +vi.mock('../../../operations/deploy', () => ({ + buildCdkProject: vi.fn(), + synthesizeCdk: vi.fn(), + checkBootstrapNeeded: vi.fn(), + bootstrapEnvironment: vi.fn(), +})); + +vi.mock('../phase1-update', () => ({ + executePhase1: vi.fn(), + getDeployedTemplate: vi.fn(), +})); + +vi.mock('../phase2-import', () => ({ + executePhase2: vi.fn(), + publishCdkAssets: vi.fn(), +})); + +vi.mock('../template-utils', () => ({ + findLogicalIdByProperty: vi.fn(), + findLogicalIdsByType: vi.fn(), +})); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('{}'), + readdirSync: vi.fn().mockReturnValue([]), + }; +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const defaultProjectSpec = { + name: 'testproj', + version: 1, + runtimes: [], + memories: [], + evaluators: [], + onlineEvalConfigs: [], +}; + +const mockConfigIO = { + readProjectSpec: vi.fn().mockResolvedValue(defaultProjectSpec), + writeProjectSpec: vi.fn().mockResolvedValue(undefined), + readDeployedState: vi.fn().mockResolvedValue({ targets: {} }), + writeDeployedState: vi.fn().mockResolvedValue(undefined), +}; + +const mockLogger = { + startStep: vi.fn(), + endStep: vi.fn(), + log: vi.fn(), + finalize: vi.fn(), + getRelativeLogPath: vi.fn().mockReturnValue('test.log'), +}; + +function setupDefaultMocks() { + mockResolveProjectContext.mockResolvedValue({ + configIO: mockConfigIO, + projectRoot: '/tmp/testproj', + projectName: 'testproj', + }); + + mockResolveImportTarget.mockResolvedValue({ + name: 'default', + region: 'us-east-1', + account: '123456789012', + }); + + mockResolveImportContext.mockResolvedValue({ + ctx: { + configIO: mockConfigIO, + projectRoot: '/tmp/testproj', + projectName: 'testproj', + }, + target: { + name: 'default', + region: 'us-east-1', + account: '123456789012', + }, + logger: mockLogger, + onProgress: vi.fn(), + }); + + mockParseAndValidateArn.mockReturnValue({ + region: 'us-east-1', + account: '123', + resourceType: 'runtime', + resourceId: 'rt-123', + }); + + mockFindResourceInDeployedState.mockResolvedValue(undefined); + + mockConfigIO.readProjectSpec.mockResolvedValue({ ...defaultProjectSpec, runtimes: [] }); + + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true }); +} + +afterEach(() => vi.clearAllMocks()); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('handleImportRuntime', () => { + describe('entrypoint resolution', () => { + it('fails with clear error when entrypoint is undetectable and no --entrypoint flag', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + // entryPoint only has non-file wrappers — no .py/.ts/.js + entryPoint: ['opentelemetry-instrument'], + }); + + const result = await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not determine entrypoint'); + expect(result.error).toContain('--entrypoint'); + }); + + it('fails with clear error when entryPoint is undefined', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: undefined, + }); + + const result = await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not determine entrypoint'); + }); + + it('fails with clear error when entryPoint is empty array', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: [], + }); + + const result = await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not determine entrypoint'); + }); + + it('uses --entrypoint flag when provided, bypassing auto-detection', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + // No detectable entrypoint from API + entryPoint: ['some-wrapper'], + }); + + // Mock will fail at CDK step, but we can verify entrypoint was accepted + // by checking that copyAgentSource was called with the provided entrypoint + mockCopyAgentSource.mockRejectedValue(new Error('stop here')); + + await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + entrypoint: 'custom_app.py', + }); + + // It should have gotten past entrypoint resolution and attempted source copy + expect(mockCopyAgentSource).toHaveBeenCalledWith( + expect.objectContaining({ + entrypoint: 'custom_app.py', + }) + ); + }); + + it('auto-detects .py entrypoint from otel wrapper array', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['opentelemetry-instrument', 'main.py'], + }); + + mockCopyAgentSource.mockRejectedValue(new Error('stop here')); + + await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + expect(mockCopyAgentSource).toHaveBeenCalledWith( + expect.objectContaining({ + entrypoint: 'main.py', + }) + ); + }); + }); + + describe('single-result auto-select', () => { + it('auto-selects when exactly 1 runtime is returned from listing', async () => { + setupDefaultMocks(); + mockListAllAgentRuntimes.mockResolvedValue([ + { agentRuntimeId: 'rt-solo', agentRuntimeArn: 'arn-solo', agentRuntimeName: 'solo-runtime', status: 'READY' }, + ]); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-solo', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-solo', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['main.py'], + }); + + // Will proceed past listing and fail at copy step — confirms auto-select worked + mockCopyAgentSource.mockRejectedValue(new Error('stop here')); + + await handleImportRuntime({ + code: '/tmp/test-source', + name: 'myagent', + // no --arn, so listing path is used + }); + + expect(mockGetAgentRuntimeDetail).toHaveBeenCalledWith(expect.objectContaining({ runtimeId: 'rt-solo' })); + }); + + it('errors with "Multiple runtimes found" when more than 1 runtime exists', async () => { + setupDefaultMocks(); + mockListAllAgentRuntimes.mockResolvedValue([ + { agentRuntimeId: 'rt-1', agentRuntimeArn: 'arn-1', agentRuntimeName: 'r1', status: 'READY' }, + { agentRuntimeId: 'rt-2', agentRuntimeArn: 'arn-2', agentRuntimeName: 'r2', status: 'READY' }, + ]); + + const result = await handleImportRuntime({ + code: '/tmp/test-source', + name: 'myagent', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Multiple runtimes found'); + }); + + it('errors when no runtimes exist', async () => { + setupDefaultMocks(); + mockListAllAgentRuntimes.mockResolvedValue([]); + + const result = await handleImportRuntime({ + code: '/tmp/test-source', + name: 'myagent', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No runtimes found'); + }); + }); + + describe('toAgentEnvSpec field mapping', () => { + it('maps environmentVariables to envVars array', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['main.py'], + environmentVariables: { API_KEY: 'secret', DB_HOST: 'localhost' }, + }); + + mockCopyAgentSource.mockResolvedValue(undefined); + + // Capture the first write to project spec (before any rollback) + let writtenSpec: Record | undefined; + mockConfigIO.writeProjectSpec.mockImplementation((spec: Record) => { + if (!writtenSpec) writtenSpec = JSON.parse(JSON.stringify(spec)) as Record; + return Promise.resolve(); + }); + + // Will fail at CDK step, but we can inspect what was written + await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + const runtimes = (writtenSpec as { runtimes: { envVars?: { name: string; value: string }[] }[] })?.runtimes; + expect(runtimes).toBeDefined(); + const addedRuntime = runtimes?.[0]; + expect(addedRuntime?.envVars).toEqual([ + { name: 'API_KEY', value: 'secret' }, + { name: 'DB_HOST', value: 'localhost' }, + ]); + }); + + it('maps tags, lifecycleConfiguration, and requestHeaderAllowlist', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['main.py'], + tags: { env: 'prod', team: 'platform' }, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }, + requestHeaderAllowlist: ['X-Custom-Header', 'Authorization'], + }); + + mockCopyAgentSource.mockResolvedValue(undefined); + + let writtenSpec: Record | undefined; + mockConfigIO.writeProjectSpec.mockImplementation((spec: Record) => { + if (!writtenSpec) writtenSpec = JSON.parse(JSON.stringify(spec)) as Record; + return Promise.resolve(); + }); + + await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + const runtimes = ( + writtenSpec as { + runtimes: { + tags?: Record; + lifecycleConfiguration?: { idleRuntimeSessionTimeout?: number; maxLifetime?: number }; + requestHeaderAllowlist?: string[]; + }[]; + } + )?.runtimes; + const addedRuntime = runtimes?.[0]; + expect(addedRuntime?.tags).toEqual({ env: 'prod', team: 'platform' }); + expect(addedRuntime?.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }); + expect(addedRuntime?.requestHeaderAllowlist).toEqual(['X-Custom-Header', 'Authorization']); + }); + + it('omits new fields when they are undefined', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['main.py'], + // No environmentVariables, tags, lifecycleConfiguration, requestHeaderAllowlist + }); + + mockCopyAgentSource.mockResolvedValue(undefined); + + let writtenSpec: Record | undefined; + mockConfigIO.writeProjectSpec.mockImplementation((spec: Record) => { + if (!writtenSpec) writtenSpec = JSON.parse(JSON.stringify(spec)) as Record; + return Promise.resolve(); + }); + + await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + const runtimes = ( + writtenSpec as { + runtimes: { + envVars?: unknown; + tags?: unknown; + lifecycleConfiguration?: unknown; + requestHeaderAllowlist?: unknown; + }[]; + } + )?.runtimes; + const addedRuntime = runtimes?.[0]; + expect(addedRuntime?.envVars).toBeUndefined(); + expect(addedRuntime?.tags).toBeUndefined(); + expect(addedRuntime?.lifecycleConfiguration).toBeUndefined(); + expect(addedRuntime?.requestHeaderAllowlist).toBeUndefined(); + }); + }); + + describe('input validation', () => { + it('fails when --code is not provided', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['main.py'], + }); + + const result = await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + name: 'myagent', + // no code option + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('--code'); + }); + + it('fails when source path does not exist', async () => { + setupDefaultMocks(); + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['main.py'], + }); + + const fs = await import('node:fs'); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/nonexistent/path', + name: 'myagent', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('does not exist'); + }); + + it('fails when runtime name already exists in project', async () => { + setupDefaultMocks(); + const fs = await import('node:fs'); + vi.mocked(fs.existsSync).mockReturnValue(true); + mockConfigIO.readProjectSpec.mockResolvedValue({ + ...defaultProjectSpec, + runtimes: [{ name: 'myagent' }], + }); + + mockGetAgentRuntimeDetail.mockResolvedValue({ + agentRuntimeId: 'rt-123', + agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + agentRuntimeName: 'testproj_myagent', + status: 'READY', + roleArn: 'arn:aws:iam::123:role/test-role', + networkMode: 'PUBLIC', + protocol: 'HTTP', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entryPoint: ['main.py'], + }); + + const result = await handleImportRuntime({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123', + code: '/tmp/test-source', + name: 'myagent', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + }); + }); +}); diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index cb9ad5c73..c0bdc337f 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -8,14 +8,10 @@ import type { Memory, } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; -import { LocalCdkProject } from '../../cdk/local-cdk-project'; -import { silentIoHost } from '../../cdk/toolkit-lib'; import { ExecLogger } from '../../logging'; -import { bootstrapEnvironment, buildCdkProject, checkBootstrapNeeded, synthesizeCdk } from '../../operations/deploy'; import { setupPythonProject } from '../../operations/python/setup'; -import { executePhase1, getDeployedTemplate } from './phase1-update'; -import { executePhase2, publishCdkAssets } from './phase2-import'; -import type { CfnTemplate } from './template-utils'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { copyDirRecursive, fixPyprojectForSetuptools, toStackName } from './import-utils'; import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; import type { ImportResult, ParsedStarterToolkitConfig, ResourceToImport } from './types'; import { parseStarterToolkitYaml } from './yaml-parser'; @@ -29,14 +25,6 @@ export interface ImportOptions { onProgress?: (message: string) => void; } -function sanitize(name: string): string { - return name.replace(/_/g, '-'); -} - -function toStackName(projectName: string, targetName: string): string { - return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; -} - /** * Convert parsed starter toolkit agents to CLI AgentEnvSpec format. */ @@ -516,160 +504,114 @@ export async function handleImport(options: ImportOptions): Promise f.endsWith('.template.json')); - if (files.length === 0) { - await toolkitWrapper.dispose(); - await rollbackConfig(); - const error = 'No CloudFormation template found in CDK assembly'; - logger.endStep('error', error); - logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; - } - synthTemplate = JSON.parse(fs.readFileSync(path.join(assemblyDirectory, files[0]!), 'utf-8')) as CfnTemplate; - } - - // 8b. Check CDK bootstrap and auto-bootstrap if needed (before disposing toolkit wrapper) - logger.log('Checking CDK bootstrap status...'); - onProgress?.('Checking CDK bootstrap status...'); - const bootstrapCheck = await checkBootstrapNeeded([target]); - if (bootstrapCheck.needsBootstrap) { - logger.log('AWS environment not bootstrapped. Bootstrapping...'); - onProgress?.('AWS environment not bootstrapped. Bootstrapping...'); - await bootstrapEnvironment(toolkitWrapper, target); - logger.log('CDK bootstrap complete'); - onProgress?.('CDK bootstrap complete'); - } - - await toolkitWrapper.dispose(); - logger.endStep('success'); - - // 8c. Publish CDK assets to S3 (source zips needed by CodeBuild during Phase 1) - logger.startStep('Publish CDK assets'); - logger.log('Publishing CDK assets to S3...'); - onProgress?.('Publishing CDK assets to S3...'); - await publishCdkAssets(assemblyDirectory, target.region, onProgress); - logger.endStep('success'); + // 8-11. CDK build → synth → bootstrap → phase 1 → phase 2 → update state + logger.startStep('Build and import via CDK'); + const progressFn = + onProgress ?? + ((_msg: string) => { + /* no-op when caller doesn't provide onProgress */ + }); - // 9. Phase 1: UPDATE — deploy companion resources - logger.startStep('Phase 1: Deploy companion resources'); - logger.log('Phase 1: Deploying companion resources (IAM roles, policies)...'); - onProgress?.('Phase 1: Deploying companion resources (IAM roles, policies)...'); - const phase1Result = await executePhase1({ - region: target.region, + const importedResources = [ + ...agentsToImport + .filter(a => a.physicalAgentId) + .map(a => ({ + type: 'runtime' as const, + name: a.name, + id: a.physicalAgentId!, + arn: + a.physicalAgentArn ?? + `arn:aws:bedrock-agentcore:${target.region}:${target.account}:runtime/${a.physicalAgentId}`, + })), + ...memoriesToImport + .filter(m => m.physicalMemoryId) + .map(m => ({ + type: 'memory' as const, + name: m.name, + id: m.physicalMemoryId!, + arn: + m.physicalMemoryArn ?? + `arn:aws:bedrock-agentcore:${target.region}:${target.account}:memory/${m.physicalMemoryId}`, + })), + ]; + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot, stackName, - synthTemplate, - onProgress, - }); - - if (!phase1Result.success) { - const error = `Phase 1 failed: ${phase1Result.error}`; - await rollbackConfig(); - logger.endStep('error', error); - logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; - } - logger.endStep('success'); - - // 10. Phase 2: IMPORT — adopt primary resources - logger.startStep('Phase 2: Import resources'); - logger.log('Reading deployed template...'); - onProgress?.('Reading deployed template...'); - const deployedTemplate = await getDeployedTemplate(target.region, stackName); - if (!deployedTemplate) { - const error = 'Could not read deployed template after Phase 1'; - await rollbackConfig(); - logger.endStep('error', error); - logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; - } - - // Build ResourcesToImport list - const resourcesToImport: ResourceToImport[] = []; - - for (const agent of agentsToImport) { - const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime'); - let logicalId: string | undefined; + target, + configIO, + targetName, + onProgress: progressFn, + buildResourcesToImport: synthTemplate => { + const resourcesToImport: ResourceToImport[] = []; + + for (const agent of agentsToImport) { + const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime'); + let logicalId: string | undefined; + + const expectedRuntimeName = `${projectName}_${agent.name}`; + logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Runtime', + 'AgentRuntimeName', + expectedRuntimeName + ); - const expectedRuntimeName = `${projectName}_${agent.name}`; - logicalId = findLogicalIdByProperty( - synthTemplate, - 'AWS::BedrockAgentCore::Runtime', - 'AgentRuntimeName', - expectedRuntimeName - ); + if (!logicalId && runtimeLogicalIds.length === 1) { + logicalId = runtimeLogicalIds[0]; + } - if (!logicalId && runtimeLogicalIds.length === 1) { - logicalId = runtimeLogicalIds[0]; - } + if (!logicalId) { + logger.log(`Warning: Could not find logical ID for agent ${agent.name}, skipping`, 'warn'); + progressFn(`Warning: Could not find logical ID for agent ${agent.name}, skipping`); + continue; + } - if (!logicalId) { - logger.log(`Warning: Could not find logical ID for agent ${agent.name}, skipping`, 'warn'); - onProgress?.(`Warning: Could not find logical ID for agent ${agent.name}, skipping`); - continue; - } + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Runtime', + logicalResourceId: logicalId, + resourceIdentifier: { AgentRuntimeId: agent.physicalAgentId! }, + }); + } - resourcesToImport.push({ - resourceType: 'AWS::BedrockAgentCore::Runtime', - logicalResourceId: logicalId, - resourceIdentifier: { AgentRuntimeId: agent.physicalAgentId! }, - }); - } + for (const memory of memoriesToImport) { + const memoryLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Memory'); + let logicalId: string | undefined; - for (const memory of memoriesToImport) { - const memoryLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Memory'); - let logicalId: string | undefined; + logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name); - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name); + // 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); + } - // 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); - } + if (!logicalId && memoryLogicalIds.length === 1) { + logicalId = memoryLogicalIds[0]; + } - if (!logicalId && memoryLogicalIds.length === 1) { - logicalId = memoryLogicalIds[0]; - } + if (!logicalId) { + logger.log(`Warning: Could not find logical ID for memory ${memory.name}, skipping`, 'warn'); + progressFn(`Warning: Could not find logical ID for memory ${memory.name}, skipping`); + continue; + } - if (!logicalId) { - logger.log(`Warning: Could not find logical ID for memory ${memory.name}, skipping`, 'warn'); - onProgress?.(`Warning: Could not find logical ID for memory ${memory.name}, skipping`); - continue; - } + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Memory', + logicalResourceId: logicalId, + resourceIdentifier: { MemoryId: memory.physicalMemoryId! }, + }); + } - resourcesToImport.push({ - resourceType: 'AWS::BedrockAgentCore::Memory', - logicalResourceId: logicalId, - resourceIdentifier: { MemoryId: memory.physicalMemoryId! }, - }); - } + return resourcesToImport; + }, + deployedStateEntries: importedResources, + }); - if (resourcesToImport.length === 0) { + if (pipelineResult.noResources) { logger.log('No resources could be matched for import'); - onProgress?.('No resources could be matched for import'); + progressFn('No resources could be matched for import'); logger.endStep('success'); logger.finalize(true); return { @@ -682,20 +624,8 @@ export async function handleImport(options: ImportOptions): Promise ({ targets: {} })); - const targetState = existingState.targets[targetName] ?? { resources: {} }; - targetState.resources ??= {}; - targetState.resources.stackName = stackName; - - if (agentsToImport.length > 0) { - targetState.resources.runtimes ??= {}; - for (const agent of agentsToImport) { - if (agent.physicalAgentId) { - targetState.resources.runtimes[agent.name] = { - runtimeId: agent.physicalAgentId, - runtimeArn: - agent.physicalAgentArn ?? - `arn:aws:bedrock-agentcore:${target.region}:${target.account}:runtime/${agent.physicalAgentId}`, - roleArn: 'imported', // Placeholder — updated after agentcore deploy - }; - } - } - } - - if (memoriesToImport.length > 0) { - targetState.resources.memories ??= {}; - for (const memory of memoriesToImport) { - if (memory.physicalMemoryId) { - targetState.resources.memories[memory.name] = { - memoryId: memory.physicalMemoryId, - memoryArn: - memory.physicalMemoryArn ?? - `arn:aws:bedrock-agentcore:${target.region}:${target.account}:memory/${memory.physicalMemoryId}`, - }; - } - } - } - - existingState.targets[targetName] = targetState; - await configIO.writeDeployedState(existingState); - /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */ - logger.endStep('success'); - logger.finalize(true); return { success: true, @@ -764,53 +650,3 @@ export async function handleImport(options: ImportOptions): Promise { - program + const importCmd = program .command('import') - .description('Import resources from a Bedrock AgentCore Starter Toolkit project') - .requiredOption('--source ', 'Path to the .bedrock_agentcore.yaml configuration file') + .description('Import a runtime, memory, or starter toolkit into this project. [experimental]'); + + // Existing YAML flow: agentcore import --source + importCmd + .option('--source ', 'Path to the .bedrock_agentcore.yaml configuration file') .option('--target ', 'Deployment target name (only needed if project has multiple targets)') .option('-y, --yes', 'Auto-confirm prompts') - .action(async (cliOptions: { source: string; target?: string; yes?: boolean }) => { + .action(async (cliOptions: { source?: string; target?: string; yes?: boolean }) => { + if (!cliOptions.source) { + // No --source and no subcommand — launch interactive TUI + const { requireProject } = await import('../../tui/guards/project'); + requireProject(); + const { render } = await import('ink'); + const React = await import('react'); + const { ImportFlow } = await import('../../tui/screens/import'); + const inkRef: { current?: { clear: () => void; unmount: () => void } } = {}; + + const exitTui = () => { + inkRef.current?.clear(); + inkRef.current?.unmount(); + }; + + const navigateTo = async (command: string) => { + exitTui(); + if (command === 'deploy') { + const { DeployScreen } = await import('../../tui/screens/deploy/DeployScreen'); + const deployInstance = render( + React.createElement(DeployScreen, { + isInteractive: false, + onExit: () => { + deployInstance.unmount(); + process.exit(0); + }, + }) + ); + } else if (command === 'status') { + const { StatusScreen } = await import('../../tui/screens/status/StatusScreen'); + const statusInstance = render( + React.createElement(StatusScreen, { + isInteractive: false, + onExit: () => { + statusInstance.unmount(); + process.exit(0); + }, + }) + ); + } + }; + + inkRef.current = render( + React.createElement(ImportFlow, { + onBack: exitTui, + onNavigate: (command: string) => void navigateTo(command), + }) + ); + return; + } + // Validate source file exists if (!fs.existsSync(cliOptions.source)) { console.error(`\x1b[31m[error]${reset} Source file not found: ${cliOptions.source}`); @@ -92,4 +146,10 @@ export const registerImport = (program: Command) => { process.exit(1); } }); + + // Register subcommands for importing individual resource types from AWS + registerImportRuntime(importCmd); + registerImportMemory(importCmd); + registerImportEvaluator(importCmd); + registerImportOnlineEval(importCmd); }; diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 780719330..93c25f902 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -1,3 +1,16 @@ +/** Name validation regex used by all import handlers. */ +export const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + +/** ANSI escape codes for console output. */ +export const ANSI = { + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + reset: '\x1b[0m', +} as const; + /** * CloudFormation resource type to identifier key mapping for IMPORT. */ @@ -5,6 +18,8 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::Runtime': ['AgentRuntimeId'], 'AWS::BedrockAgentCore::Memory': ['MemoryId'], 'AWS::BedrockAgentCore::Gateway': ['GatewayIdentifier'], + '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 new file mode 100644 index 000000000..be85829f3 --- /dev/null +++ b/src/cli/commands/import/import-evaluator.ts @@ -0,0 +1,153 @@ +import type { Evaluator } from '../../../schema'; +import type { EvaluatorSummary, GetEvaluatorResult } from '../../aws/agentcore-control'; +import { + getEvaluator, + getOnlineEvaluationConfig, + listAllEvaluators, + listAllOnlineEvaluationConfigs, +} from '../../aws/agentcore-control'; +import { ANSI } from './constants'; +import { failResult, parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Map an AWS GetEvaluator response to the CLI Evaluator spec format. + */ +export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): Evaluator { + const level = detail.level || 'SESSION'; + + let config: Evaluator['config']; + + if (detail.evaluatorConfig?.llmAsAJudge) { + const llm = detail.evaluatorConfig.llmAsAJudge; + config = { + llmAsAJudge: { + model: llm.model, + instructions: llm.instructions, + ratingScale: llm.ratingScale, + }, + }; + } else if (detail.evaluatorConfig?.codeBased) { + config = { + codeBased: { + external: { + lambdaArn: detail.evaluatorConfig.codeBased.lambdaArn, + }, + }, + }; + } else { + throw new Error( + `Evaluator "${detail.evaluatorName}" has no recognizable config. ` + + 'Only LLM-as-a-Judge and code-based evaluators can be imported.' + ); + } + + return { + name: localName, + level, + ...(detail.description && { description: detail.description }), + config, + ...(detail.tags && Object.keys(detail.tags).length > 0 && { tags: detail.tags }), + }; +} + +const evaluatorDescriptor: ResourceImportDescriptor = { + resourceType: 'evaluator', + displayName: 'evaluator', + logCommand: 'import-evaluator', + + listResources: region => listAllEvaluators({ region }), + getDetail: (region, id) => getEvaluator({ region, evaluatorId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'evaluator', target).resourceId, + + extractSummaryId: s => s.evaluatorId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.evaluatorName} — ${s.status}\n ${ANSI.dim}${s.evaluatorArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 evaluator: ${s.evaluatorName} (${s.evaluatorId}). Auto-selecting.`, + + extractDetailName: d => d.evaluatorName, + extractDetailArn: d => d.evaluatorArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, + + getExistingNames: spec => (spec.evaluators ?? []).map(e => e.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.evaluators ??= []).push(toEvaluatorSpec(detail, localName)); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::Evaluator', + cfnNameProperty: 'EvaluatorName', + cfnIdentifierKey: 'EvaluatorId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), + + beforeConfigWrite: async ({ detail, localName, target, onProgress, logger }) => { + // Check if any online eval config references this evaluator. + // CFN IMPORT of locked evaluators always fails because CFN triggers a + // post-import TagResource call that the resource handler rejects. + logger.startStep('Check for online eval config references'); + onProgress('Checking if evaluator is referenced by an online eval config...'); + + const oecSummaries = await listAllOnlineEvaluationConfigs({ region: target.region }); + if (oecSummaries.length > 0) { + const oecDetails = await Promise.all( + oecSummaries.map(s => + getOnlineEvaluationConfig({ region: target.region, configId: s.onlineEvaluationConfigId }) + ) + ); + + const referencingOec = oecDetails.find(oec => oec.evaluatorIds?.includes(detail.evaluatorId)); + + if (referencingOec) { + return failResult( + logger, + `Evaluator "${localName}" is referenced by online eval config "${referencingOec.configName}" and cannot be imported directly (locked by CloudFormation).\n` + + `To import this evaluator along with its online eval config, run:\n` + + ` agentcore import online-eval --arn ${referencingOec.configArn}`, + 'evaluator', + localName + ); + } + } + + logger.endStep('success'); + }, +}; + +/** + * Handle `agentcore import evaluator`. + */ +export async function handleImportEvaluator(options: ImportResourceOptions): Promise { + return executeResourceImport(evaluatorDescriptor, options); +} + +/** + * Register the `import evaluator` subcommand. + */ +export function registerImportEvaluator(importCmd: Command): void { + importCmd + .command('evaluator') + .description('Import an existing AgentCore Evaluator from your AWS account') + .option('--arn ', 'Evaluator ARN to import') + .option('--name ', 'Local name for the imported evaluator') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportEvaluator(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Evaluator imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + 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-memory.ts b/src/cli/commands/import/import-memory.ts new file mode 100644 index 000000000..81740b726 --- /dev/null +++ b/src/cli/commands/import/import-memory.ts @@ -0,0 +1,131 @@ +import type { Memory } from '../../../schema'; +import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control'; +import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control'; +import { ANSI } from './constants'; +import { parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Map strategy type from AWS API format to CLI schema format. + * The API returns types like "SEMANTIC_OVERRIDE", "SUMMARY_OVERRIDE", etc. + * CLI uses "SEMANTIC", "SUMMARIZATION", "USER_PREFERENCE", "EPISODIC". + */ +function mapStrategyType(apiType: string): string { + const mapping: Record = { + SEMANTIC_OVERRIDE: 'SEMANTIC', + SUMMARY_OVERRIDE: 'SUMMARIZATION', + USER_PREFERENCE_OVERRIDE: 'USER_PREFERENCE', + EPISODIC_OVERRIDE: 'EPISODIC', + // Direct mappings + SEMANTIC: 'SEMANTIC', + SUMMARIZATION: 'SUMMARIZATION', + USER_PREFERENCE: 'USER_PREFERENCE', + EPISODIC: 'EPISODIC', + }; + return mapping[apiType] ?? apiType; +} + +/** + * Filter out API-internal namespace patterns that are auto-generated + * and should not be included in local config. + * These patterns contain template variables like {memoryStrategyId}, {actorId}, etc. + */ +function filterInternalNamespaces(namespaces: string[]): string[] { + return namespaces.filter(ns => !ns.includes('{memoryStrategyId}')); +} + +/** + * Map an AWS GetMemory response to the CLI Memory format. + */ +function toMemorySpec(memory: MemoryDetail, localName: string): Memory { + const strategies: Memory['strategies'] = memory.strategies.map(s => { + const mappedType = mapStrategyType(s.type); + const filteredNamespaces = s.namespaces ? filterInternalNamespaces(s.namespaces) : []; + return { + type: mappedType as Memory['strategies'][number]['type'], + ...(s.name && { name: s.name }), + ...(s.description && { description: s.description }), + ...(filteredNamespaces.length > 0 && { namespaces: filteredNamespaces }), + ...(s.reflectionNamespaces && + s.reflectionNamespaces.length > 0 && { reflectionNamespaces: s.reflectionNamespaces }), + }; + }); + + return { + name: localName, + eventExpiryDuration: Math.max(7, Math.min(365, memory.eventExpiryDuration)), + strategies, + ...(memory.tags && Object.keys(memory.tags).length > 0 && { tags: memory.tags }), + ...(memory.encryptionKeyArn && { encryptionKeyArn: memory.encryptionKeyArn }), + ...(memory.executionRoleArn && { executionRoleArn: memory.executionRoleArn }), + }; +} + +const memoryDescriptor: ResourceImportDescriptor = { + resourceType: 'memory', + displayName: 'memory', + logCommand: 'import-memory', + + listResources: region => listAllMemories({ region }), + getDetail: (region, id) => getMemoryDetail({ region, memoryId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'memory', target).resourceId, + + extractSummaryId: s => s.memoryId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.memoryId} — ${s.status}\n ${ANSI.dim}${s.memoryArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 memory: ${s.memoryId}. Auto-selecting.`, + + extractDetailName: d => d.name, + extractDetailArn: d => d.memoryArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, + + getExistingNames: spec => (spec.memories ?? []).map(m => m.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.memories ??= []).push(toMemorySpec(detail, localName)); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::Memory', + cfnNameProperty: 'Name', + cfnIdentifierKey: 'MemoryId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'memory', name, id, arn: d.memoryArn }), +}; + +/** + * Handle `agentcore import memory`. + */ +export async function handleImportMemory(options: ImportResourceOptions): Promise { + return executeResourceImport(memoryDescriptor, options); +} + +/** + * Register the `import memory` subcommand. + */ +export function registerImportMemory(importCmd: Command): void { + importCmd + .command('memory') + .description('Import an existing AgentCore Memory from your AWS account') + .option('--arn ', 'Memory ARN to import') + .option('--name ', 'Local name for the imported memory') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportMemory(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Memory imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + 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-online-eval.ts b/src/cli/commands/import/import-online-eval.ts new file mode 100644 index 000000000..298ea45fd --- /dev/null +++ b/src/cli/commands/import/import-online-eval.ts @@ -0,0 +1,218 @@ +import type { OnlineEvalConfig } from '../../../schema'; +import type { GetOnlineEvalConfigResult, OnlineEvalConfigSummary } from '../../aws/agentcore-control'; +import { + getOnlineEvaluationConfig, + listAllAgentRuntimes, + listAllOnlineEvaluationConfigs, +} from '../../aws/agentcore-control'; +import { ANSI } from './constants'; +import { failResult, findResourceInDeployedState, parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Derive the agent name from the online eval config's service names. + * Service names follow the pattern: "{agentName}.DEFAULT" + */ +export function extractAgentName(serviceNames: string[]): string | undefined { + if (serviceNames.length === 0) return undefined; + const serviceName = serviceNames[0]!; + const dotIndex = serviceName.lastIndexOf('.'); + if (dotIndex === -1) return serviceName; + return serviceName.slice(0, dotIndex); +} + +/** + * Map an AWS GetOnlineEvaluationConfig response to the CLI OnlineEvalConfig spec format. + */ +export function toOnlineEvalConfigSpec( + detail: GetOnlineEvalConfigResult, + localName: string, + agentName: string, + evaluatorArns: string[] +): OnlineEvalConfig { + if (detail.samplingPercentage == null) { + throw new Error(`Online eval config "${detail.configName}" has no sampling configuration. Cannot import.`); + } + + return { + name: localName, + agent: agentName, + evaluators: evaluatorArns, + samplingRate: detail.samplingPercentage, + ...(detail.description && { description: detail.description }), + ...(detail.executionStatus === 'ENABLED' && { enableOnCreate: true }), + }; +} + +/** + * Build evaluator ARNs from evaluator IDs. + * Online eval configs reference evaluators by ARN rather than importing them, + * since evaluators locked by an online eval config cannot be CFN-imported. + */ +function buildEvaluatorArns(evaluatorIds: string[], region: string, account: string): string[] { + return evaluatorIds.map(id => `arn:aws:bedrock-agentcore:${region}:${account}:evaluator/${id}`); +} + +/** + * Create an online-eval descriptor with closed-over state for reference resolution. + */ +function createOnlineEvalDescriptor(): ResourceImportDescriptor { + // Set by beforeConfigWrite, read by addToProjectSpec. Ordering guaranteed by executeResourceImport. + let resolvedAgentName = ''; + let resolvedEvaluatorArns: string[] = []; + + return { + resourceType: 'online-eval', + displayName: 'online eval config', + logCommand: 'import-online-eval', + + listResources: region => listAllOnlineEvaluationConfigs({ region }), + getDetail: (region, id) => getOnlineEvaluationConfig({ region, configId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'online-eval', target).resourceId, + + extractSummaryId: s => s.onlineEvaluationConfigId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.onlineEvaluationConfigName} — ${s.status} (${s.executionStatus})\n ${ANSI.dim}${s.onlineEvaluationConfigArn}${ANSI.reset}`, + formatAutoSelectMessage: s => + `Found 1 config: ${s.onlineEvaluationConfigName} (${s.onlineEvaluationConfigId}). Auto-selecting.`, + + extractDetailName: d => d.configName, + extractDetailArn: d => d.configArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, + + getExistingNames: spec => (spec.onlineEvalConfigs ?? []).map(c => c.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.onlineEvalConfigs ??= []).push( + toOnlineEvalConfigSpec(detail, localName, resolvedAgentName, resolvedEvaluatorArns) + ); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + cfnNameProperty: 'OnlineEvaluationConfigName', + cfnIdentifierKey: 'OnlineEvaluationConfigId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'online-eval', name, id, arn: d.configArn }), + + beforeConfigWrite: async ({ detail, localName, projectSpec, ctx, target, onProgress, logger }) => { + logger.startStep('Resolve references'); + + // Extract agent name from service names + const awsAgentName = extractAgentName(detail.serviceNames ?? []); + if (!awsAgentName) { + return failResult( + logger, + 'Could not determine agent name from online eval config. The config has no data source service names.', + 'online-eval', + localName + ); + } + + // Resolve the local agent name. The AWS name from the OEC service names + // may differ from the local name if the runtime was imported with --name, + // or it may include the CDK project prefix ("{projectName}_{agentName}"). + const agentNames = new Set((projectSpec.runtimes ?? []).map(r => r.name)); + let agentName: string | undefined; + + if (agentNames.has(awsAgentName)) { + // Direct match — local name equals AWS name + agentName = awsAgentName; + } else { + // Strip CDK project prefix if present (service names use "{projectName}_{agentName}") + const prefix = `${ctx.projectName}_`; + if (awsAgentName.startsWith(prefix)) { + const stripped = awsAgentName.slice(prefix.length); + if (agentNames.has(stripped)) { + agentName = stripped; + } + } + } + + if (!agentName) { + // Look up the AWS runtime ID for the AWS name, then find the local name + // that maps to it in deployed state. + onProgress(`Agent "${awsAgentName}" not found by name, checking deployed state...`); + const runtimes = await listAllAgentRuntimes({ region: target.region }); + const matchingRuntime = runtimes.find(r => r.agentRuntimeName === awsAgentName); + + if (matchingRuntime) { + const targetName = target.name ?? 'default'; + const localMatch = await findResourceInDeployedState( + ctx.configIO, + targetName, + 'runtime', + matchingRuntime.agentRuntimeId + ); + if (localMatch && agentNames.has(localMatch)) { + agentName = localMatch; + onProgress(`Resolved AWS runtime "${awsAgentName}" to local name "${agentName}"`); + } + } + } + + if (!agentName) { + return failResult( + logger, + `Online eval config references agent "${awsAgentName}" which is not in this project. ` + + `Import or add the agent first with \`agentcore import runtime\` or \`agentcore add agent\`.`, + 'online-eval', + localName + ); + } + + // Resolve evaluator IDs to ARNs + const evaluatorIds = detail.evaluatorIds ?? []; + if (evaluatorIds.length === 0) { + return failResult( + logger, + 'Online eval config has no evaluators configured. Cannot import.', + 'online-eval', + localName + ); + } + + resolvedEvaluatorArns = buildEvaluatorArns(evaluatorIds, target.region, target.account); + resolvedAgentName = agentName; + onProgress(`Agent: ${agentName}, Evaluators: ${resolvedEvaluatorArns.join(', ')}`); + logger.endStep('success'); + }, + }; +} + +/** + * Handle `agentcore import online-eval`. + */ +export async function handleImportOnlineEval(options: ImportResourceOptions): Promise { + return executeResourceImport(createOnlineEvalDescriptor(), options); +} + +/** + * Register the `import online-eval` subcommand. + */ +export function registerImportOnlineEval(importCmd: Command): void { + importCmd + .command('online-eval') + .description('Import an existing AgentCore Online Evaluation Config from your AWS account') + .option('--arn ', 'Online evaluation config ARN to import') + .option('--name ', 'Local name for the imported online eval config') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportOnlineEval(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Online eval config imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + 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 new file mode 100644 index 000000000..6f6444f9a --- /dev/null +++ b/src/cli/commands/import/import-pipeline.ts @@ -0,0 +1,145 @@ +import type { ConfigIO } from '../../../lib'; +import type { AwsDeploymentTarget } from '../../../schema'; +import { LocalCdkProject } from '../../cdk/local-cdk-project'; +import { silentIoHost } from '../../cdk/toolkit-lib'; +import { bootstrapEnvironment, buildCdkProject, checkBootstrapNeeded, synthesizeCdk } from '../../operations/deploy'; +import type { ImportedResource } from './import-utils'; +import { updateDeployedState } from './import-utils'; +import { executePhase1, getDeployedTemplate } from './phase1-update'; +import { executePhase2, publishCdkAssets } from './phase2-import'; +import type { CfnTemplate } from './template-utils'; +import type { ResourceToImport } from './types'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface CdkImportPipelineInput { + projectRoot: string; + stackName: string; + target: AwsDeploymentTarget; + configIO: ConfigIO; + targetName: string; + onProgress: (message: string) => void; + + /** Caller builds the import resource list from the synthesized template. */ + buildResourcesToImport: (synthTemplate: CfnTemplate) => ResourceToImport[]; + + /** Entries to write into deployed-state.json after a successful import. */ + deployedStateEntries: ImportedResource[]; +} + +export interface CdkImportPipelineResult { + success: boolean; + error?: string; + /** True when buildResourcesToImport returned an empty list. Callers decide if this is an error. */ + noResources?: boolean; +} + +/** + * Shared CDK import pipeline: build → synth → bootstrap → publish assets → phase 1 → phase 2 → update state. + * + * Callers handle resource-specific logic (AWS fetching, config mutation, name validation) + * and delegate the CDK/CloudFormation work to this function. + */ +export async function executeCdkImportPipeline(input: CdkImportPipelineInput): Promise { + const { + projectRoot, + stackName, + target, + configIO, + targetName, + onProgress, + buildResourcesToImport, + deployedStateEntries, + } = input; + + // 1. Build CDK project + onProgress('Building CDK project...'); + const cdkProject = new LocalCdkProject(projectRoot); + await buildCdkProject(cdkProject); + + // 2. Synthesize CloudFormation template + onProgress('Synthesizing CloudFormation template...'); + const synthResult = await synthesizeCdk(cdkProject, { ioHost: silentIoHost }); + const { toolkitWrapper } = synthResult; + + const synthInfo = await toolkitWrapper.synth(); + const assemblyDirectory = synthInfo.assemblyDirectory; + const synthTemplatePath = path.join(assemblyDirectory, `${stackName}.template.json`); + + let synthTemplate: CfnTemplate; + try { + synthTemplate = JSON.parse(fs.readFileSync(synthTemplatePath, 'utf-8')) as CfnTemplate; + } catch { + const files = fs.readdirSync(assemblyDirectory).filter((f: string) => f.endsWith('.template.json')); + if (files.length === 0) { + await toolkitWrapper.dispose(); + return { success: false, error: 'No CloudFormation template found in CDK assembly' }; + } + synthTemplate = JSON.parse(fs.readFileSync(path.join(assemblyDirectory, files[0]!), 'utf-8')) as CfnTemplate; + } + + // 3. Check CDK bootstrap and auto-bootstrap if needed + onProgress('Checking CDK bootstrap status...'); + const bootstrapCheck = await checkBootstrapNeeded([target]); + if (bootstrapCheck.needsBootstrap) { + onProgress('Bootstrapping AWS environment...'); + await bootstrapEnvironment(toolkitWrapper, target); + onProgress('CDK bootstrap complete'); + } + + await toolkitWrapper.dispose(); + + // 4. Publish CDK assets to S3 + onProgress('Publishing CDK assets to S3...'); + await publishCdkAssets(assemblyDirectory, target.region, onProgress); + + // 5. Phase 1: Deploy companion resources + onProgress('Phase 1: Deploying companion resources (IAM roles, policies)...'); + const phase1Result = await executePhase1({ + region: target.region, + stackName, + synthTemplate, + onProgress, + }); + + if (!phase1Result.success) { + return { success: false, error: `Phase 1 failed: ${phase1Result.error}` }; + } + + // 6. Read deployed template + onProgress('Reading deployed template...'); + const deployedTemplate = await getDeployedTemplate(target.region, stackName); + if (!deployedTemplate) { + return { success: false, error: 'Could not read deployed template after Phase 1' }; + } + + // 7. Build resources to import (caller-specific logic) + const resourcesToImport = buildResourcesToImport(synthTemplate); + + if (resourcesToImport.length === 0) { + return { success: true, noResources: true }; + } + + // 8. Phase 2: Import resources via CloudFormation + onProgress(`Phase 2: Importing ${resourcesToImport.length} resource(s) via CloudFormation IMPORT...`); + const phase2Result = await executePhase2({ + region: target.region, + stackName, + deployedTemplate, + synthTemplate, + resourcesToImport, + assemblyDirectory, + onProgress, + }); + + if (!phase2Result.success) { + return { success: false, error: `Phase 2 failed: ${phase2Result.error}` }; + } + + // 9. Update deployed state + onProgress('Updating deployed state...'); + await updateDeployedState(configIO, targetName, stackName, deployedStateEntries); + onProgress('Deployed state updated'); + + return { success: true }; +} diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts new file mode 100644 index 000000000..b1921d546 --- /dev/null +++ b/src/cli/commands/import/import-runtime.ts @@ -0,0 +1,235 @@ +import type { AgentEnvSpec } from '../../../schema'; +import type { AgentRuntimeDetail, AgentRuntimeSummary } from '../../aws/agentcore-control'; +import { getAgentRuntimeDetail, listAllAgentRuntimes } from '../../aws/agentcore-control'; +import { ANSI } from './constants'; +import { copyAgentSource, failResult, parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceResult, ResourceImportDescriptor, RuntimeImportOptions } from './types'; +import type { Command } from '@commander-js/extra-typings'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Extract the actual entrypoint file from the runtime's entryPoint array. + * The array may contain wrapper commands like "opentelemetry-instrument" + * before the actual Python/TS file (e.g. ["opentelemetry-instrument", "main.py"]). + */ +export function extractEntrypoint(entryPoint?: string[]): string | undefined { + if (!entryPoint || entryPoint.length === 0) return undefined; + // Find the first entry that looks like a source file + return entryPoint.find(e => /\.(py|ts|js)$/.test(e)); +} + +/** + * Map an AWS GetAgentRuntime response to the CLI AgentEnvSpec format. + */ +function toAgentEnvSpec( + runtime: AgentRuntimeDetail, + localName: string, + codeLocation: string, + entrypoint: string +): AgentEnvSpec { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ + const runtimeVersion = + runtime.build === 'Container' ? runtime.runtimeVersion : (runtime.runtimeVersion ?? 'PYTHON_3_12'); + const spec: AgentEnvSpec = { + name: localName, + ...(runtime.description && { description: runtime.description }), + build: runtime.build, + entrypoint: entrypoint as any, + codeLocation: codeLocation as any, + runtimeVersion: runtimeVersion as any, + protocol: runtime.protocol as any, + networkMode: runtime.networkMode as any, + instrumentation: { enableOtel: true }, + }; + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ + + if (runtime.networkMode === 'VPC' && runtime.networkConfig) { + spec.networkConfig = runtime.networkConfig; + } + + if (runtime.roleArn && runtime.roleArn !== 'imported') { + spec.executionRoleArn = runtime.roleArn; + } + + if (runtime.authorizerType) { + spec.authorizerType = runtime.authorizerType as AgentEnvSpec['authorizerType']; + } + if (runtime.authorizerConfiguration) { + spec.authorizerConfiguration = runtime.authorizerConfiguration as AgentEnvSpec['authorizerConfiguration']; + } + + if (runtime.environmentVariables && Object.keys(runtime.environmentVariables).length > 0) { + spec.envVars = Object.entries(runtime.environmentVariables).map(([name, value]) => ({ name, value })); + } + + if (runtime.tags && Object.keys(runtime.tags).length > 0) { + spec.tags = runtime.tags; + } + + if (runtime.lifecycleConfiguration) { + spec.lifecycleConfiguration = runtime.lifecycleConfiguration; + } + + if (runtime.requestHeaderAllowlist && runtime.requestHeaderAllowlist.length > 0) { + spec.requestHeaderAllowlist = runtime.requestHeaderAllowlist; + } + + return spec; +} + +/** + * Create a runtime descriptor with closed-over state for entrypoint, code location, and rollback. + */ +function createRuntimeDescriptor( + options: RuntimeImportOptions +): ResourceImportDescriptor { + let resolvedEntrypoint = ''; + let resolvedCodeLocation = ''; + let copiedAppDir: string | undefined; + + return { + resourceType: 'runtime', + displayName: 'runtime', + logCommand: 'import-runtime', + + listResources: region => listAllAgentRuntimes({ region }), + getDetail: (region, id) => getAgentRuntimeDetail({ region, runtimeId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'runtime', target).resourceId, + + extractSummaryId: s => s.agentRuntimeId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.agentRuntimeName} — ${s.status}\n ${ANSI.dim}${s.agentRuntimeArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 runtime: ${s.agentRuntimeName} (${s.agentRuntimeId}). Auto-selecting.`, + + extractDetailName: d => d.agentRuntimeName, + extractDetailArn: d => d.agentRuntimeArn, + readyStatus: 'READY', + extractDetailStatus: d => d.status, + + getExistingNames: spec => spec.runtimes.map(r => r.name), + addToProjectSpec: (detail, localName, spec) => { + spec.runtimes.push(toAgentEnvSpec(detail, localName, resolvedCodeLocation, resolvedEntrypoint)); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::Runtime', + cfnNameProperty: 'AgentRuntimeName', + cfnIdentifierKey: 'AgentRuntimeId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'runtime', name, id, arn: d.agentRuntimeArn }), + + beforeConfigWrite: async ({ detail, localName, ctx, onProgress, logger }) => { + // Resolve entrypoint + logger.startStep('Resolve entrypoint'); + const entrypoint = options.entrypoint ?? extractEntrypoint(detail.entryPoint); + if (!entrypoint) { + return failResult( + logger, + 'Could not determine entrypoint from runtime configuration.\n Please re-run with --entrypoint to specify it manually.', + 'runtime', + localName + ); + } + onProgress(`Entrypoint: ${entrypoint}`); + logger.endStep('success'); + + // Validate source path + logger.startStep('Validate source path'); + if (!options.code) { + return failResult( + logger, + 'Source path is required for runtime import. Use --code to specify the agent source code directory.', + 'runtime', + localName + ); + } + + const sourcePath = path.resolve(options.code); + if (!fs.existsSync(sourcePath)) { + return failResult(logger, `Source path does not exist: ${sourcePath}`, 'runtime', localName); + } + const entrypointPath = path.join(sourcePath, entrypoint); + if (!fs.existsSync(entrypointPath)) { + return failResult( + logger, + `Entrypoint file '${entrypoint}' not found in ${sourcePath}. Ensure --code points to the directory containing your entrypoint file.`, + 'runtime', + localName + ); + } + logger.endStep('success'); + + // Copy agent source + logger.startStep('Copy agent source'); + resolvedCodeLocation = `app/${localName}/`; + resolvedEntrypoint = entrypoint; + copiedAppDir = path.join(ctx.projectRoot, 'app', localName); + await copyAgentSource({ + sourcePath, + agentName: localName, + projectRoot: ctx.projectRoot, + build: detail.build, + entrypoint, + onProgress, + }); + logger.endStep('success'); + }, + + // eslint-disable-next-line @typescript-eslint/require-await + rollbackExtra: async () => { + if (copiedAppDir && fs.existsSync(copiedAppDir)) { + try { + fs.rmSync(copiedAppDir, { recursive: true, force: true }); + } catch (err) { + console.warn( + `Warning: Could not clean up ${copiedAppDir}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + }, + }; +} + +/** + * Handle `agentcore import runtime`. + */ +export async function handleImportRuntime(options: RuntimeImportOptions): Promise { + return executeResourceImport(createRuntimeDescriptor(options), options); +} + +/** + * Register the `import runtime` subcommand. + */ +export function registerImportRuntime(importCmd: Command): void { + importCmd + .command('runtime') + .description('Import an existing AgentCore Runtime from your AWS account') + .option('--arn ', 'Runtime ARN to import') + .option('--code ', 'Path to the directory containing the entrypoint file (e.g., the folder with main.py)') + .option('--entrypoint ', 'Entrypoint file (auto-detected from runtime, e.g. main.py)') + .option('--name ', 'Local name for the imported runtime') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: RuntimeImportOptions) => { + const result = await handleImportRuntime(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Runtime 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 invoke ${ANSI.dim}Test your agent${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-utils.ts b/src/cli/commands/import/import-utils.ts new file mode 100644 index 000000000..d224870ec --- /dev/null +++ b/src/cli/commands/import/import-utils.ts @@ -0,0 +1,489 @@ +import { APP_DIR, ConfigIO, findConfigRoot } from '../../../lib'; +import type { AwsDeploymentTarget } from '../../../schema'; +import { detectAccount, validateAwsCredentials } from '../../aws/account'; +import { ExecLogger } from '../../logging'; +import { setupPythonProject } from '../../operations/python/setup'; +import { getTemplatePath } from '../../templates/templateRoot'; +import { ANSI } from './constants'; +import type { ImportResourceOptions, ImportResourceResult, ImportableResourceType } from './types'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// ============================================================================ +// Import Context (shared setup for import-runtime / import-memory) +// ============================================================================ + +const { green, reset } = ANSI; + +export interface ImportContext { + ctx: ProjectContext; + target: AwsDeploymentTarget; + logger: ExecLogger; + onProgress: (message: string) => void; +} + +/** + * Shared setup for single-resource import commands (runtime, memory). + * Validates project context, resolves deployment target, and creates logger. + */ +export async function resolveImportContext(options: ImportResourceOptions, command: string): Promise { + const logger = new ExecLogger({ command }); + const onProgress = + options.onProgress ?? + ((message: string) => { + console.log(`${green}[done]${reset} ${message}`); + }); + + logger.startStep('Validate project context'); + const ctx = await resolveProjectContext(); + logger.endStep('success'); + + logger.startStep('Resolve deployment target'); + const target = await resolveImportTarget({ + configIO: ctx.configIO, + targetName: options.target, + arn: options.arn, + onProgress, + }); + logger.endStep('success'); + + return { ctx, target, logger, onProgress }; +} + +// ============================================================================ +// Error Result Helper +// ============================================================================ + +/** + * Build a failed ImportResourceResult, logging the error and finalizing the logger. + */ +export function failResult( + logger: ExecLogger, + error: string, + resourceType: ImportableResourceType, + resourceName: string +): ImportResourceResult { + logger.endStep('error', error); + logger.finalize(false); + return { + success: false, + error, + resourceType, + resourceName, + logPath: logger.getRelativeLogPath(), + }; +} + +// ============================================================================ +// Project Context +// ============================================================================ + +export interface ProjectContext { + projectRoot: string; + configRoot: string; + configIO: ConfigIO; + projectName: string; +} + +/** + * Validate we're inside an agentcore project and return project context. + */ +export async function resolveProjectContext(): Promise { + const configRoot = findConfigRoot(process.cwd()); + if (!configRoot) { + throw new Error( + 'No agentcore project found in the current directory.\nRun `agentcore create ` first, then run import from inside the project.' + ); + } + + const projectRoot = path.dirname(configRoot); + const configIO = new ConfigIO({ baseDir: configRoot }); + const projectSpec = await configIO.readProjectSpec(); + + return { + projectRoot, + configRoot, + configIO, + projectName: projectSpec.name, + }; +} + +// ============================================================================ +// Target Resolution +// ============================================================================ + +export interface ResolveTargetOptions { + configIO: ConfigIO; + targetName?: string; + arn?: string; + logger?: ExecLogger; + onProgress?: (message: string) => void; +} + +/** + * Resolve the deployment target (account + region) for import. + * Validates AWS credentials. + */ +export async function resolveImportTarget(options: ResolveTargetOptions): Promise { + const { configIO, targetName, arn, onProgress } = options; + + // Validate ARN format early if provided + if ( + arn && + !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.test(arn) + ) { + throw new Error( + `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` + ); + } + + let targets = await configIO.readAWSDeploymentTargets(); + + if (targets.length === 0) { + if (!arn) { + throw new Error( + 'No deployment targets found in project.\nRun `agentcore deploy` first to set up a target, or use --arn so a target can be created automatically.' + ); + } + + const arnMatch = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):/.exec(arn); + if (!arnMatch) { + throw new Error( + 'No deployment targets found in project and could not parse region/account from ARN.\nRun `agentcore deploy` first to set up a target, then re-run import.' + ); + } + + const [, arnRegion, arnAccount] = arnMatch; + const newTarget: AwsDeploymentTarget = { + name: 'default', + description: `Default target (${arnRegion})`, + account: arnAccount!, + region: arnRegion! as AwsDeploymentTarget['region'], + }; + + onProgress?.(`No deployment targets found. Creating default target from ARN (${arnRegion}, ${arnAccount})...`); + await configIO.writeAWSDeploymentTargets([newTarget]); + targets = [newTarget]; + } + + let target: AwsDeploymentTarget | undefined; + + if (targetName) { + target = targets.find(t => t.name === targetName); + if (!target) { + const names = targets.map(t => ` - ${t.name} (${t.region}, ${t.account})`).join('\n'); + throw new Error(`Target "${targetName}" not found. Available targets:\n${names}`); + } + } else if (targets.length === 1) { + target = targets[0]!; + } else { + const names = targets.map(t => ` - ${t.name} (${t.region}, ${t.account})`).join('\n'); + throw new Error(`Multiple deployment targets found. Specify one with --target:\n${names}`); + } + + onProgress?.(`Using target: ${target.name} (${target.region}, ${target.account})`); + + // Validate AWS credentials + onProgress?.('Validating AWS credentials...'); + await validateAwsCredentials(); + + // Validate credentials match the target account + const callerAccount = await detectAccount(); + if (callerAccount && target.account && callerAccount !== target.account) { + throw new Error( + `Your AWS credentials are for account ${callerAccount}, but the target "${target.name}" is configured for account ${target.account}.\nEnsure your credentials match the deployment target.` + ); + } + + return target; +} + +// ============================================================================ +// ARN Validation +// ============================================================================ + +export interface ParsedArn { + region: string; + account: string; + resourceType: string; + resourceId: string; +} + +const ARN_PATTERN = + /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; + +/** Unified config for each importable resource type — ARN mapping, deployed state keys. */ +const RESOURCE_TYPE_CONFIG: Record< + ImportableResourceType, + { + arnType: string; + collectionKey: string; + idField: string; + } +> = { + runtime: { arnType: 'runtime', collectionKey: 'runtimes', idField: 'runtimeId' }, + memory: { arnType: 'memory', collectionKey: 'memories', idField: 'memoryId' }, + evaluator: { arnType: 'evaluator', collectionKey: 'evaluators', idField: 'evaluatorId' }, + 'online-eval': { + arnType: 'online-evaluation-config', + collectionKey: 'onlineEvalConfigs', + idField: 'onlineEvaluationConfigId', + }, +}; + +/** + * Parse and validate a BedrockAgentCore ARN. + * Validates format, region, and account against the deployment target. + */ +export function parseAndValidateArn( + arn: string, + expectedResourceType: ImportableResourceType, + target: { region: string; account: string } +): ParsedArn { + const match = ARN_PATTERN.exec(arn); + const expectedArnType = RESOURCE_TYPE_CONFIG[expectedResourceType].arnType; + if (!match) { + throw new Error( + `Invalid ARN format: "${arn}". Expected format: arn:aws:bedrock-agentcore:::${expectedArnType}/` + ); + } + + const [, region, account, resourceType, resourceId] = match; + + if (resourceType !== expectedArnType) { + throw new Error(`ARN resource type "${resourceType}" does not match expected type "${expectedArnType}".`); + } + + if (region !== target.region) { + throw new Error( + `ARN region "${region}" does not match target region "${target.region}". Use --target to select a different deployment target.` + ); + } + + if (account !== target.account) { + throw new Error( + `ARN account "${account}" does not match target account "${target.account}". Ensure the ARN belongs to the correct account.` + ); + } + + return { region, account, resourceType, resourceId: resourceId! }; +} + +// ============================================================================ +// Stack Name +// ============================================================================ + +function replaceUnderscoresWithDashes(name: string): string { + return name.replace(/_/g, '-'); +} + +export function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${replaceUnderscoresWithDashes(projectName)}-${replaceUnderscoresWithDashes(targetName)}`; +} + +// ============================================================================ +// Deployed State Update +// ============================================================================ + +/** + * Check if a resource ID is already tracked in deployed-state.json for the given target. + * Returns the name it's tracked under, or undefined if not found. + */ +export async function findResourceInDeployedState( + configIO: ConfigIO, + targetName: string, + resourceType: ImportableResourceType, + resourceId: string +): Promise { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */ + const state: any = await configIO.readDeployedState().catch(() => ({ targets: {} })); + const targetState = state.targets?.[targetName]; + if (!targetState?.resources) return undefined; + + const { collectionKey, idField } = RESOURCE_TYPE_CONFIG[resourceType]; + + const collection = targetState.resources[collectionKey]; + if (!collection) return undefined; + for (const [name, entry] of Object.entries(collection)) { + if ((entry as any)[idField] === resourceId) return name; + } + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */ + + return undefined; +} + +export interface ImportedResource { + type: ImportableResourceType; + name: string; + id: string; + arn: string; +} + +/** + * Update deployed-state.json with imported resource IDs. + */ +export async function updateDeployedState( + configIO: ConfigIO, + targetName: string, + stackName: string, + resources: ImportedResource[] +): Promise { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ + const existingState: any = await configIO.readDeployedState().catch(() => ({ targets: {} })); + const targetState = existingState.targets[targetName] ?? { resources: {} }; + targetState.resources ??= {}; + targetState.resources.stackName = stackName; + + for (const resource of resources) { + if (resource.type === 'runtime') { + targetState.resources.runtimes ??= {}; + targetState.resources.runtimes[resource.name] = { + runtimeId: resource.id, + runtimeArn: resource.arn, + roleArn: 'imported', + }; + } else if (resource.type === 'memory') { + targetState.resources.memories ??= {}; + targetState.resources.memories[resource.name] = { + memoryId: resource.id, + memoryArn: resource.arn, + }; + } else if (resource.type === 'evaluator') { + targetState.resources.evaluators ??= {}; + targetState.resources.evaluators[resource.name] = { + evaluatorId: resource.id, + evaluatorArn: resource.arn, + }; + } else if (resource.type === 'online-eval') { + targetState.resources.onlineEvalConfigs ??= {}; + targetState.resources.onlineEvalConfigs[resource.name] = { + onlineEvaluationConfigId: resource.id, + onlineEvaluationConfigArn: resource.arn, + }; + } + } + + existingState.targets[targetName] = targetState; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await configIO.writeDeployedState(existingState); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ +} + +// ============================================================================ +// Source Code Copy +// ============================================================================ + +const COPY_EXCLUDE_DIRS = new Set([ + '.venv', + '.git', + '__pycache__', + 'node_modules', + '.pytest_cache', + '.bedrock_agentcore', + '.mypy_cache', + '.ruff_cache', +]); + +/** + * Recursively copy directory contents, skipping excluded directories and symlinks. + */ +export function copyDirRecursive(src: string, dest: string): void { + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + if (COPY_EXCLUDE_DIRS.has(entry.name)) continue; + if (!fs.existsSync(destPath)) { + fs.mkdirSync(destPath, { recursive: true }); + } + copyDirRecursive(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Fix pyproject.toml for setuptools auto-discovery issues. + */ +export function fixPyprojectForSetuptools(pyprojectPath: string): void { + if (!fs.existsSync(pyprojectPath)) return; + + const content = fs.readFileSync(pyprojectPath, 'utf-8'); + + if (content.includes('[tool.setuptools]')) return; + + // Append the fix + fs.writeFileSync(pyprojectPath, content.trimEnd() + '\n\n[tool.setuptools]\npy-modules = []\n'); +} + +export interface CopyAgentSourceOptions { + sourcePath: string; + agentName: string; + projectRoot: string; + build: 'CodeZip' | 'Container'; + entrypoint?: string; + onProgress?: (message: string) => void; +} + +/** + * Copy agent source code into the project's app// directory. + * Handles pyproject.toml, Dockerfile, Python env setup. + */ +export async function copyAgentSource(options: CopyAgentSourceOptions): Promise { + const { sourcePath, agentName, projectRoot, build, onProgress } = options; + + const appDir = path.join(projectRoot, APP_DIR, agentName); + if (!fs.existsSync(appDir)) { + fs.mkdirSync(appDir, { recursive: true }); + } + + if (fs.existsSync(sourcePath)) { + onProgress?.(`Copying agent source from ${sourcePath} to ./${APP_DIR}/${agentName}`); + copyDirRecursive(sourcePath, appDir); + + const parentPyproject = path.join(path.dirname(sourcePath), 'pyproject.toml'); + const destPyproject = path.join(appDir, 'pyproject.toml'); + if (fs.existsSync(parentPyproject) && !fs.existsSync(destPyproject)) { + fs.copyFileSync(parentPyproject, destPyproject); + } + + // For Container builds, generate a Dockerfile if missing + if (build === 'Container') { + const destDockerfile = path.join(appDir, 'Dockerfile'); + if (!fs.existsSync(destDockerfile)) { + const isPython = options.entrypoint?.endsWith('.py') ?? true; + if (isPython) { + onProgress?.('Generating Dockerfile for Container build'); + const entryModule = path.basename(options.entrypoint ?? 'main.py', '.py'); + const templatePath = getTemplatePath('container', 'python', 'Dockerfile'); + const template = fs.readFileSync(templatePath, 'utf-8'); + fs.writeFileSync(destDockerfile, template.replace('{{entrypoint}}', entryModule)); + } else { + onProgress?.( + 'No Dockerfile found. Please add a Dockerfile to the source directory for non-Python container builds.' + ); + } + } + } + } else { + throw new Error(`Source path does not exist: ${sourcePath}`); + } + + // Container agents install dependencies inside the Docker image + if (build !== 'Container') { + fixPyprojectForSetuptools(path.join(appDir, 'pyproject.toml')); + + onProgress?.(`Setting up Python environment for ${agentName}...`); + const setupResult = await setupPythonProject({ projectDir: appDir }); + if (setupResult.status === 'success') { + onProgress?.(`Python environment ready for ${agentName}`); + } else if (setupResult.status === 'uv_not_found') { + onProgress?.(`Warning: uv not found — run "uv sync" manually in ${APP_DIR}/${agentName}`); + } else { + onProgress?.(`Warning: Python setup failed for ${agentName}: ${setupResult.error ?? setupResult.status}`); + } + } +} diff --git a/src/cli/commands/import/phase2-import.ts b/src/cli/commands/import/phase2-import.ts index 9d08e223a..d898785f3 100644 --- a/src/cli/commands/import/phase2-import.ts +++ b/src/cli/commands/import/phase2-import.ts @@ -117,6 +117,18 @@ export async function executePhase2(options: Phase2Options): Promise( + descriptor: ResourceImportDescriptor, + 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)}`); + } + } + if (descriptor.rollbackExtra) { + await descriptor.rollbackExtra(); + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, descriptor.logCommand); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Fetch resource from AWS + logger.startStep(`Fetch ${descriptor.displayName} from AWS`); + let resourceId: string; + + if (options.arn) { + resourceId = descriptor.parseResourceId(options.arn, target); + } else { + onProgress(`Listing ${descriptor.displayName}s in your account...`); + const summaries = await descriptor.listResources(target.region); + + if (summaries.length === 0) { + return failResult(logger, `No ${descriptor.displayName}s found in your account.`, descriptor.resourceType, ''); + } + + if (summaries.length === 1) { + resourceId = descriptor.extractSummaryId(summaries[0]!); + onProgress(descriptor.formatAutoSelectMessage(summaries[0]!)); + } else { + console.log(`\nFound ${summaries.length} ${descriptor.displayName}(s):\n`); + for (let i = 0; i < summaries.length; i++) { + console.log(descriptor.formatListItem(summaries[i]!, i)); + } + console.log(''); + + return failResult( + logger, + `Multiple ${descriptor.displayName}s found. Use --arn to specify which ${descriptor.displayName} to import.`, + descriptor.resourceType, + '' + ); + } + } + + onProgress(`Fetching ${descriptor.displayName} details for ${resourceId}...`); + const detail = await descriptor.getDetail(target.region, resourceId); + + if (descriptor.extractDetailStatus(detail) !== descriptor.readyStatus) { + onProgress( + `Warning: ${descriptor.displayName} status is ${descriptor.extractDetailStatus(detail)}, not ${descriptor.readyStatus}` + ); + } + + // 4. Validate name + const localName = options.name ?? descriptor.extractDetailName(detail); + if (!NAME_REGEX.test(localName)) { + return failResult( + logger, + `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, + descriptor.resourceType, + localName + ); + } + onProgress(`${descriptor.displayName}: ${descriptor.extractDetailName(detail)} → 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(descriptor.getExistingNames(projectSpec)); + if (existingNames.has(localName)) { + return failResult( + logger, + `${descriptor.displayName} "${localName}" already exists in the project. Use --name to specify a different local name.`, + descriptor.resourceType, + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState( + ctx.configIO, + targetName, + descriptor.resourceType, + resourceId + ); + 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 + ); + } + logger.endStep('success'); + + // 6. Optional pre-write hook + if (descriptor.beforeConfigWrite) { + const hookResult = await descriptor.beforeConfigWrite({ + detail, + localName, + projectSpec, + ctx, + target, + options, + onProgress, + logger, + }); + if (hookResult) { + return hookResult; + } + } + + // 7. Update project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + descriptor.addToProjectSpec(detail, localName, projectSpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added ${descriptor.displayName} "${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); + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: synthTemplate => { + // Try matching by name property (plain name first, then prefixed) + let logicalId = findLogicalIdByProperty( + synthTemplate, + descriptor.cfnResourceType, + descriptor.cfnNameProperty, + localName + ); + + if (!logicalId) { + const prefixedName = `${ctx.projectName}_${localName}`; + logicalId = findLogicalIdByProperty( + synthTemplate, + descriptor.cfnResourceType, + descriptor.cfnNameProperty, + prefixedName + ); + } + + // Fall back to single resource by type + if (!logicalId) { + const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType); + if (allLogicalIds.length === 1) { + logicalId = allLogicalIds[0]; + } + } + + if (!logicalId) { + return []; + } + + return [ + { + resourceType: descriptor.cfnResourceType, + logicalResourceId: logicalId, + resourceIdentifier: { [descriptor.cfnIdentifierKey]: resourceId }, + }, + ]; + }, + deployedStateEntries: [descriptor.buildDeployedStateEntry(localName, resourceId, detail)], + }); + + if (pipelineResult.noResources) { + const error = `Could not find logical ID for ${descriptor.displayName} "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, descriptor.resourceType, localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: descriptor.resourceType, + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + // 9. Return success + logger.finalize(true); + return { + success: true, + resourceType: descriptor.resourceType, + resourceName: localName, + resourceId, + 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: 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 97ac6d97e..eab11c0a8 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -1,4 +1,11 @@ -import type { AgentCoreProjectSpec, AuthorizerConfig, RuntimeAuthorizerType } from '../../../schema'; +import type { + AgentCoreProjectSpec, + AuthorizerConfig, + AwsDeploymentTarget, + RuntimeAuthorizerType, +} from '../../../schema'; +import type { ExecLogger } from '../../logging'; +import type { ImportedResource, ProjectContext } from './import-utils'; /** * Parsed representation of a starter toolkit agent from .bedrock_agentcore.yaml. @@ -63,6 +70,13 @@ 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 type ImportableResourceType = (typeof IMPORTABLE_RESOURCES)[number]; + /** * Resource to be imported via CloudFormation IMPORT change set. */ @@ -84,3 +98,145 @@ export interface ImportResult { stackName?: string; logPath?: string; } + +/** + * Result for single-resource import (runtime, memory, evaluator, etc.). + */ +export interface ImportResourceResult { + success: boolean; + error?: string; + resourceType: ImportableResourceType; + resourceName: string; + resourceId?: string; + logPath?: string; +} + +/** + * Options shared across import subcommands. + */ +export interface ImportResourceOptions { + arn?: string; + target?: string; + name?: string; + yes?: boolean; + onProgress?: (message: string) => void; +} + +/** + * Extended options for runtime import (includes source code fields). + */ +export interface RuntimeImportOptions extends ImportResourceOptions { + code?: string; + entrypoint?: string; +} + +// ============================================================================ +// Generic Resource Import Descriptor +// ============================================================================ + +/** + * Context passed to the beforeConfigWrite hook. + */ +export interface BeforeWriteContext { + detail: TDetail; + localName: string; + projectSpec: AgentCoreProjectSpec; + ctx: ProjectContext; + target: AwsDeploymentTarget; + options: ImportResourceOptions; + onProgress: (msg: string) => void; + logger: ExecLogger; +} + +/** + * Descriptor that defines resource-type-specific behavior for the generic import orchestrator. + * + * TDetail: The AWS "get" API response type (e.g., GetEvaluatorResult) + * TSummary: The AWS "list" API response item type (e.g., EvaluatorSummary) + */ +export interface ResourceImportDescriptor { + /** The importable resource type identifier. */ + resourceType: ImportableResourceType; + + /** Human-readable resource type name for log messages (e.g., "evaluator"). */ + displayName: string; + + /** Logger command name (e.g., 'import-evaluator'). */ + logCommand: string; + + // ---- AWS API ---- + + /** List all resources of this type in the region. */ + listResources: (region: string) => Promise; + + /** Get full details for a single resource by ID. */ + getDetail: (region: string, resourceId: string) => Promise; + + /** Extract the resource ID from an ARN. */ + parseResourceId: (arn: string, target: { region: string; account: string }) => string; + + // ---- List display ---- + + /** Extract ID from a summary item. */ + extractSummaryId: (summary: TSummary) => string; + + /** Format a summary item for console display in multi-result listing. */ + formatListItem: (summary: TSummary, index: number) => string; + + /** Format the auto-select message when exactly 1 result is found. */ + formatAutoSelectMessage: (summary: TSummary) => string; + + // ---- Detail inspection ---- + + /** Extract the canonical name from the detail response. */ + extractDetailName: (detail: TDetail) => string; + + /** Extract the ARN from the detail response. */ + extractDetailArn: (detail: TDetail) => string; + + /** The expected "ready" status value (e.g., 'READY' for runtime, 'ACTIVE' for others). */ + readyStatus: string; + + /** Extract the current status from the detail response. */ + extractDetailStatus: (detail: TDetail) => string; + + // ---- Config ---- + + /** Get the array of existing resource names from the project spec. */ + getExistingNames: (projectSpec: AgentCoreProjectSpec) => string[]; + + /** + * Convert the AWS detail to local spec and add it to the project spec. + * Called after beforeConfigWrite — descriptor factories may rely on state set during that hook. + */ + addToProjectSpec: (detail: TDetail, localName: string, projectSpec: AgentCoreProjectSpec) => void; + + // ---- CFN template matching ---- + + /** CloudFormation resource type string. */ + cfnResourceType: string; + + /** CFN property name used for name-based lookup. */ + cfnNameProperty: string; + + /** CFN resource identifier key for the import. */ + cfnIdentifierKey: string; + + // ---- Deployed state ---- + + /** Build the deployed-state entry for this resource. */ + buildDeployedStateEntry: (localName: string, resourceId: string, detail: TDetail) => ImportedResource; + + // ---- Optional hooks ---- + + /** + * Called after detail fetch + name validation but before config write. + * Always runs before addToProjectSpec — descriptor factories can use this + * to set closed-over state that addToProjectSpec later reads. + * Return an ImportResourceResult to abort, or void to continue. + */ + beforeConfigWrite?: (ctx: BeforeWriteContext) => Promise; + + /** Cleanup on rollback (e.g., runtime deletes copied app directory). */ + rollbackExtra?: () => Promise; +} diff --git a/src/cli/commands/invoke/__tests__/validate.test.ts b/src/cli/commands/invoke/__tests__/validate.test.ts index 652b02e1c..301d15e6c 100644 --- a/src/cli/commands/invoke/__tests__/validate.test.ts +++ b/src/cli/commands/invoke/__tests__/validate.test.ts @@ -33,4 +33,32 @@ describe('validateInvokeOptions', () => { it('returns valid with agentName and targetName', () => { expect(validateInvokeOptions({ agentName: 'my-agent', targetName: 'default' })).toEqual({ valid: true }); }); + + it('returns invalid when exec is true but no prompt', () => { + const result = validateInvokeOptions({ exec: true }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--exec'); + }); + + it('returns invalid when exec is combined with --tool', () => { + const result = validateInvokeOptions({ exec: true, prompt: 'ls', tool: 'myTool' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--exec cannot be combined'); + }); + + it('returns invalid when exec is combined with --input', () => { + const result = validateInvokeOptions({ exec: true, prompt: 'ls', input: '{}' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--exec cannot be combined'); + }); + + it('returns invalid when exec is combined with --stream', () => { + const result = validateInvokeOptions({ exec: true, prompt: 'ls', stream: true }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--exec already streams'); + }); + + it('returns valid with exec and prompt', () => { + expect(validateInvokeOptions({ exec: true, prompt: 'ls -la' })).toEqual({ valid: true }); + }); }); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index f34549e35..2e1a60696 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -1,6 +1,7 @@ import { ConfigIO } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; import { + executeBashCommand, invokeA2ARuntime, invokeAgentRuntime, invokeAgentRuntimeStreaming, @@ -106,6 +107,104 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } } + // Exec mode: run shell command in runtime container + if (options.exec) { + const logger = new InvokeLogger({ + agentName: agentSpec.name, + runtimeArn: agentState.runtimeArn, + region: targetConfig.region, + }); + const command = options.prompt; + if (!command) { + return { success: false, error: '--exec requires a command (prompt)' }; + } + logger.logPrompt(command, undefined, options.userId); + + try { + const result = await executeBashCommand({ + region: targetConfig.region, + runtimeArn: agentState.runtimeArn, + command, + sessionId: options.sessionId, + timeout: options.timeout, + headers: options.headers, + bearerToken: options.bearerToken, + }); + + let stdout = ''; + let stderr = ''; + let exitCode: number | undefined; + let status: string | undefined; + + for await (const event of result.stream) { + switch (event.type) { + case 'stdout': + if (event.data) { + stdout += event.data; + if (!options.json) { + process.stdout.write(event.data); + } + } + break; + case 'stderr': + if (event.data) { + stderr += event.data; + if (!options.json) { + process.stderr.write(event.data); + } + } + break; + case 'stop': + exitCode = event.exitCode; + status = event.status; + break; + } + } + + logger.logResponse(stdout || stderr || `exit code: ${exitCode}`); + + if (options.json) { + return { + success: exitCode === 0, + agentName: agentSpec.name, + targetName: selectedTargetName, + response: JSON.stringify({ stdout, stderr, exitCode, status }), + logFilePath: logger.logFilePath, + }; + } + + if (exitCode === undefined) { + return { + success: false, + agentName: agentSpec.name, + targetName: selectedTargetName, + error: 'Command stream ended without exit code', + logFilePath: logger.logFilePath, + }; + } + + if (exitCode !== 0) { + return { + success: false, + agentName: agentSpec.name, + targetName: selectedTargetName, + error: `Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`, + logFilePath: logger.logFilePath, + }; + } + + return { + success: true, + agentName: agentSpec.name, + targetName: selectedTargetName, + logFilePath: logger.logFilePath, + }; + } catch (err) { + logger.logError(err, 'exec command failed'); + throw err; + } + } + // MCP protocol handling if (agentSpec.protocol === 'MCP') { const mcpOpts = { @@ -214,9 +313,6 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } } - // modelProvider has been removed from schema - const providerInfo = undefined; - // Create logger for this invocation const logger = new InvokeLogger({ agentName: agentSpec.name, @@ -255,7 +351,6 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption targetName: selectedTargetName, response: fullResponse, logFilePath: logger.logFilePath, - providerInfo, }; } catch (err) { logger.logError(err, 'invoke streaming failed'); @@ -282,6 +377,5 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption targetName: selectedTargetName, response: response.content, logFilePath: logger.logFilePath, - providerInfo, }; } diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 6e1aaebc7..c808aea53 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -42,8 +42,8 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { try { const context = await loadInvokeConfig(); - // Show spinner for non-streaming, non-json invocations - if (!options.stream && !options.json) { + // Show spinner for non-streaming, non-json, non-exec invocations + if (!options.stream && !options.json && !options.exec) { spinner = startSpinner('Invoking agent...'); } @@ -62,9 +62,6 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { } } else { // Non-streaming, non-json: print provider info and response or error - if (result.providerInfo) { - console.error(`Provider: ${result.providerInfo}`); - } if (result.success && result.response) { console.log(result.response); } else if (!result.success && result.error) { @@ -104,6 +101,8 @@ export const registerInvoke = (program: Command) => { .option('--stream', 'Stream response in real-time (TUI streams by default) [non-interactive]') .option('--tool ', 'MCP tool name (use with "call-tool" prompt) [non-interactive]') .option('--input ', 'MCP tool arguments as JSON (use with --tool) [non-interactive]') + .option('--exec', 'Execute a shell command in the runtime container [non-interactive]') + .option('--timeout ', 'Timeout in seconds for --exec commands [non-interactive]', parseInt) .option( '-H, --header
    ', 'Custom header to forward to the agent (format: "Name: Value", repeatable) [non-interactive]', @@ -124,6 +123,8 @@ export const registerInvoke = (program: Command) => { stream?: boolean; tool?: string; input?: string; + exec?: boolean; + timeout?: number; header?: string[]; bearerToken?: string; } @@ -147,6 +148,7 @@ export const registerInvoke = (program: Command) => { cliOptions.stream || cliOptions.runtime || cliOptions.tool || + cliOptions.exec || cliOptions.bearerToken ) { await handleInvokeCLI({ @@ -159,6 +161,8 @@ export const registerInvoke = (program: Command) => { stream: cliOptions.stream, tool: cliOptions.tool, input: cliOptions.input, + exec: cliOptions.exec, + timeout: cliOptions.timeout, headers, bearerToken: cliOptions.bearerToken, }); diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index cebe2258e..8d8175095 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -10,6 +10,10 @@ export interface InvokeOptions { tool?: string; /** MCP tool arguments as JSON string (used with --tool) */ input?: string; + /** Execute a shell command in the runtime container instead of invoking the agent */ + exec?: boolean; + /** Timeout in seconds for exec commands */ + timeout?: number; /** Custom headers to forward to the agent runtime (key-value pairs) */ headers?: Record; /** Bearer token for CUSTOM_JWT auth (bypasses SigV4) */ @@ -23,6 +27,4 @@ export interface InvokeResult { response?: string; error?: string; logFilePath?: string; - /** Model provider (e.g., "Anthropic", "Bedrock") */ - providerInfo?: string; } diff --git a/src/cli/commands/invoke/validate.ts b/src/cli/commands/invoke/validate.ts index d68f985dc..dd97241b8 100644 --- a/src/cli/commands/invoke/validate.ts +++ b/src/cli/commands/invoke/validate.ts @@ -6,6 +6,15 @@ export interface ValidationResult { } export function validateInvokeOptions(options: InvokeOptions): ValidationResult { + if (options.exec && !options.prompt) { + return { valid: false, error: 'A command is required with --exec. Usage: agentcore invoke --exec "ls -la"' }; + } + if (options.exec && (options.tool || options.input)) { + return { valid: false, error: '--exec cannot be combined with --tool or --input' }; + } + if (options.exec && options.stream) { + return { valid: false, error: '--exec already streams output; --stream is not needed' }; + } if (options.json && !options.prompt) { return { valid: false, error: 'Prompt is required for JSON output' }; } diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index fb2213921..dd72c1b7e 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -72,7 +72,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') @@ -81,10 +81,10 @@ export const registerRun = (program: Command) => { '--endpoint ', '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('--lookback ', 'How far back to search for traces in CloudWatch (days)', '7') + .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( @@ -100,7 +100,7 @@ export const registerRun = (program: Command) => { assertion?: string[]; expectedTrajectory?: string; expectedResponse?: string; - days: string; + lookback: string; output?: string; json?: boolean; }) => { @@ -133,7 +133,7 @@ export const registerRun = (program: Command) => { ? cliOptions.expectedTrajectory.split(',').map(s => s.trim()) : undefined, expectedResponse: cliOptions.expectedResponse, - days: parseInt(cliOptions.days, 10), + days: parseInt(cliOptions.lookback, 10), output: cliOptions.output, json: cliOptions.json, }; @@ -164,16 +164,16 @@ export const registerRun = (program: Command) => { runCmd .command('batch-evaluation') - .description('Run a batch evaluation against agent sessions') - .requiredOption('-a, --agent ', 'Agent name from project config') - .requiredOption('-e, --evaluator ', 'Evaluator ID(s) (Builtin.* or custom)') + .description('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('--region ', 'AWS region (auto-detected if omitted)') - .option('--execution-role ', 'IAM execution role ARN (temporary — will be removed)') + .option('--execution-role ', 'IAM execution role ARN for batch evaluation') .option('--json', 'Output as JSON') .action( async (cliOptions: { - agent: string; + runtime: string; evaluator: string[]; name?: string; region?: string; @@ -184,7 +184,7 @@ export const registerRun = (program: Command) => { try { const result = await runBatchEvaluationCommand({ - agent: cliOptions.agent, + agent: cliOptions.runtime, evaluators: cliOptions.evaluator, name: cliOptions.name, region: cliOptions.region, @@ -230,25 +230,31 @@ export const registerRun = (program: Command) => { runCmd .command('recommendation') - .description('Run an optimization recommendation for system prompt or tool descriptions') - .option('-t, --type ', 'What to optimize: system-prompt or tool-description') - .option('-a, --agent ', 'Agent name from project') - .option('-e, --evaluator ', 'Evaluator name(s) or Builtin.* ID(s) (repeatable)') - .option('--prompt-file ', 'Load system prompt from file') - .option('--inline ', 'Provide content inline') - .option('--bundle-name ', 'Config bundle name') - .option('--bundle-version ', 'Config bundle version') - .option('--tools ', 'Comma-separated toolName:description pairs (for tool-description type)') - .option('--spans-file ', 'JSON file with session spans (inline traces instead of CloudWatch)') - .option('--lookback ', 'Lookback window in days', '7') - .option('-s, --session-id ', 'Specific session IDs for traces') - .option('-r, --run ', 'Run name prefix') + .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(s) — required for system-prompt, optional for tool-description' + ) + .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( + '--tools ', + 'Tool name:description pairs, comma-separated (e.g. "search:Searches the web,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; - agent?: string; + runtime?: string; evaluator?: string[]; promptFile?: string; inline?: string; @@ -276,11 +282,11 @@ export const registerRun = (program: Command) => { process.exit(1); } - const agent = cliOptions.agent; + const agent = cliOptions.runtime; const evaluators = cliOptions.evaluator; if (!agent) { - const error = '--agent is required'; + const error = '--runtime is required'; if (cliOptions.json) { console.log(JSON.stringify({ success: false, error })); } else { @@ -289,8 +295,9 @@ export const registerRun = (program: Command) => { process.exit(1); } - if (!evaluators || evaluators.length === 0) { - const error = '--evaluator is required (at least one)'; + // Evaluator is required for system-prompt recs, optional for tool-description + if (recType === 'SYSTEM_PROMPT_RECOMMENDATION' && (!evaluators || evaluators.length === 0)) { + const error = '--evaluator is required for system-prompt recommendations'; if (cliOptions.json) { console.log(JSON.stringify({ success: false, error })); } else { @@ -317,7 +324,7 @@ export const registerRun = (program: Command) => { const result = await runRecommendationCommand({ type: recType, agent, - evaluators, + evaluators: evaluators ?? [], promptFile: cliOptions.promptFile, inlineContent: cliOptions.inline, bundleName: cliOptions.bundleName, @@ -344,7 +351,7 @@ export const registerRun = (program: Command) => { // Save results locally try { if (result.recommendationId) { - saveRecommendationRun(result.recommendationId, result, recType, agent, evaluators); + saveRecommendationRun(result.recommendationId, result, recType, agent, evaluators ?? []); } } catch { // Non-fatal — skip saving @@ -396,37 +403,57 @@ function formatBatchEvalOutput(result: RunBatchEvaluationCommandResult): void { console.log(`\nBatch Evaluation: ${result.name ?? result.batchEvaluateId}`); console.log(`ID: ${result.batchEvaluateId}`); console.log(`Status: ${result.status}`); - console.log(`Results: ${result.results.length}\n`); - if (result.results.length === 0) { - console.log(' No evaluation results found.'); - return; + // Show session stats from API if available + const evalResults = result.evaluationResults; + if (evalResults) { + const parts: string[] = []; + if (evalResults.totalSessions != null) parts.push(`${evalResults.totalSessions} sessions`); + if (evalResults.sessionsCompleted != null) parts.push(`${evalResults.sessionsCompleted} completed`); + if (evalResults.sessionsFailed) parts.push(`${evalResults.sessionsFailed} failed`); + if (parts.length > 0) console.log(`Sessions: ${parts.join(', ')}`); } - // Group by evaluator - const byEvaluator = new Map(); - for (const r of result.results) { - const group = byEvaluator.get(r.evaluatorId) ?? []; - group.push(r); - byEvaluator.set(r.evaluatorId, group); - } + console.log(''); - for (const [evalId, evalResults] of byEvaluator) { - 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; - const errorSuffix = errors > 0 ? ` (${errors} errors)` : ''; + // 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}`); + console.log(` ${evalId}: ${avg.toFixed(2)} avg${errorSuffix}`); - for (const r of evalResults) { - 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}`); + 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/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index 7430dc431..1c3a089dc 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -1,6 +1,7 @@ import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema/index.js'; import { computeResourceStatuses, handleProjectStatus } from '../action.js'; import type { StatusContext } from '../action.js'; +import { buildRuntimeInvocationUrl } from '../constants.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockGetAgentRuntimeStatus = vi.fn(); @@ -322,6 +323,19 @@ describe('computeResourceStatuses', () => { expect(evalEntry!.detail).toBe('TRACE — LLM-as-a-Judge'); }); + it('shows Code-based detail for code-based evaluator', () => { + const project = { + ...baseProject, + evaluators: [{ name: 'CodeEval', level: 'SESSION', config: { codeBased: { managed: {} } } }], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const evalEntry = result.find(r => r.resourceType === 'evaluator' && r.name === 'CodeEval'); + + expect(evalEntry).toBeDefined(); + expect(evalEntry!.detail).toBe('SESSION — Code-based'); + }); + it('marks evaluator as pending-removal when deployed but removed from schema', () => { const resources: DeployedResourceState = { evaluators: { @@ -618,3 +632,176 @@ describe('handleProjectStatus — live enrichment', () => { expect(mockGetEvaluator).not.toHaveBeenCalled(); }); }); + +describe('buildRuntimeInvocationUrl', () => { + it('constructs the correct invocation URL with encoded ARN', () => { + const url = buildRuntimeInvocationUrl( + 'us-east-1', + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/travelplanner_FlightsMcp-abcdefgh' + ); + expect(url).toBe( + 'https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A123456789012%3Aruntime%2Ftravelplanner_FlightsMcp-abcdefgh/invocations' + ); + }); + + it('handles different regions', () => { + const url = buildRuntimeInvocationUrl( + 'eu-west-1', + 'arn:aws:bedrock-agentcore:eu-west-1:111111111111:runtime/my-agent-xyz' + ); + expect(url).toBe( + 'https://bedrock-agentcore.eu-west-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aeu-west-1%3A111111111111%3Aruntime%2Fmy-agent-xyz/invocations' + ); + }); +}); + +describe('handleProjectStatus — invocation URL enrichment', () => { + beforeEach(() => { + mockGetAgentRuntimeStatus.mockReset(); + mockGetEvaluator.mockReset(); + mockGetOnlineEvaluationConfig.mockReset(); + }); + + afterEach(() => vi.clearAllMocks()); + + it('sets invocationUrl on deployed agents after runtime status enrichment', async () => { + const runtimeArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/proj_MyAgent-abc123'; + + mockGetAgentRuntimeStatus.mockResolvedValue({ + runtimeId: 'proj_MyAgent-abc123', + status: 'READY', + }); + + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [{ name: 'MyAgent' }], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: { + runtimes: { + MyAgent: { + runtimeId: 'proj_MyAgent-abc123', + runtimeArn, + roleArn: 'arn:aws:iam::123456789012:role/test', + }, + }, + }, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => 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` + ); + }); + + it('does not set invocationUrl on local-only agents', async () => { + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [{ name: 'LocalAgent' }], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: {}, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'LocalAgent'); + expect(agentEntry).toBeDefined(); + expect(agentEntry!.invocationUrl).toBeUndefined(); + }); + + it('still sets invocationUrl when runtime status fetch fails', async () => { + const runtimeArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/proj_FailAgent-xyz'; + mockGetAgentRuntimeStatus.mockRejectedValue(new Error('Timeout')); + + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [{ name: 'FailAgent' }], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: { + runtimes: { + FailAgent: { + runtimeId: 'proj_FailAgent-xyz', + runtimeArn, + roleArn: 'arn:aws:iam::123456789012:role/test', + }, + }, + }, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'FailAgent'); + expect(agentEntry).toBeDefined(); + expect(agentEntry!.error).toBe('Timeout'); + expect(agentEntry!.invocationUrl).toBe( + `https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${encodeURIComponent(runtimeArn)}/invocations` + ); + }); + + it('does not set invocationUrl on pending-removal agents', async () => { + mockGetAgentRuntimeStatus.mockResolvedValue({ + runtimeId: 'proj_OldAgent-abc', + status: 'READY', + }); + + const ctx: StatusContext = { + project: { + ...baseProject, + runtimes: [], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789012' }], + deployedState: { + targets: { + dev: { + resources: { + runtimes: { + OldAgent: { + runtimeId: 'proj_OldAgent-abc', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/proj_OldAgent-abc', + roleArn: 'arn:aws:iam::123456789012:role/test', + }, + }, + }, + }, + }, + }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + expect(result.success).toBe(true); + const agentEntry = result.resources.find(r => 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 810cc1ec7..564b64b50 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -5,6 +5,7 @@ import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-con import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import type { ResourceDeploymentState } from './constants'; +import { buildRuntimeInvocationUrl } from './constants'; export type { ResourceDeploymentState }; @@ -24,6 +25,7 @@ export interface ResourceStatusEntry { identifier?: string; detail?: string; error?: string; + invocationUrl?: string; } export interface ProjectStatusResult { @@ -161,7 +163,7 @@ export function computeResourceStatuses( localItems: project.evaluators ?? [], deployedRecord: resources?.evaluators ?? {}, getIdentifier: deployed => deployed.evaluatorArn, - getLocalDetail: item => `${item.level} — LLM-as-a-Judge`, + getLocalDetail: item => `${item.level} — ${item.config.codeBased ? 'Code-based' : 'LLM-as-a-Judge'}`, }); const onlineEvalConfigs = diffResourceSet({ @@ -294,16 +296,20 @@ export async function handleProjectStatus( const agentState = agentStates[entry.name]; if (!agentState) return; + const invocationUrl = entry.identifier + ? buildRuntimeInvocationUrl(targetConfig.region, entry.identifier) + : undefined; + try { const runtimeStatus = await getAgentRuntimeStatus({ region: targetConfig.region, runtimeId: agentState.runtimeId, }); - resources[i] = { ...entry, detail: runtimeStatus.status }; + resources[i] = { ...entry, detail: runtimeStatus.status, invocationUrl }; logger.log(` ${entry.name}: ${runtimeStatus.status} (${agentState.runtimeId})`); } catch (error) { const errorMsg = getErrorMessage(error); - resources[i] = { ...entry, error: errorMsg }; + resources[i] = { ...entry, error: errorMsg, invocationUrl }; logger.log(` ${entry.name}: ERROR - ${errorMsg}`, 'error'); } }) diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index f7335362c..9e6de5e37 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -154,7 +154,14 @@ export const registerStatus = (program: Command) => { Agents {agents.map(entry => ( - + + + {entry.invocationUrl && ( + + {' '}URL: {entry.invocationUrl} + + )} + ))} )} diff --git a/src/cli/commands/status/constants.ts b/src/cli/commands/status/constants.ts index 6dfbc08d7..e9b047d5d 100644 --- a/src/cli/commands/status/constants.ts +++ b/src/cli/commands/status/constants.ts @@ -13,3 +13,8 @@ export const DEPLOYMENT_STATE_LABELS: Record = 'local-only': 'Local only', 'pending-removal': 'Removed locally', }; + +export function buildRuntimeInvocationUrl(region: string, runtimeArn: string): string { + const encodedArn = encodeURIComponent(runtimeArn); + return `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/${encodedArn}/invocations`; +} diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 06d420de0..322cca523 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -115,6 +115,7 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { return { name: config.projectName, build: config.buildType ?? 'CodeZip', + ...(config.dockerfile && { dockerfile: config.dockerfile }), entrypoint: DEFAULT_PYTHON_ENTRYPOINT as FilePath, codeLocation: codeLocation as DirectoryPath, runtimeVersion: DEFAULT_PYTHON_VERSION, @@ -276,5 +277,6 @@ export async function mapGenerateConfigToRenderConfig( gatewayProviders, gatewayAuthTypes: [...new Set(gatewayProviders.map(g => g.authType))], protocol: config.protocol, + dockerfile: config.dockerfile, }; } 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 index f0be87290..16edc86aa 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -88,7 +88,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components: { foo: { type: 'inline', value: 'old' } }, description: undefined, - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); mockUpdateConfigurationBundle.mockResolvedValue({ @@ -111,7 +111,7 @@ describe('setupConfigBundles', () => { bundleId: 'b-123', components: { foo: { type: 'inline', value: 'new' } }, parentVersionIds: ['v-1'], - branchName: 'mainline', + branchName: 'main', commitMessage: 'Update MyBundle', }) ); @@ -137,7 +137,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components, description: 'My desc', - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); const result = await setupConfigBundles({ @@ -172,7 +172,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components: { a: { type: 'inline', value: '1' }, b: { type: 'inline', value: '2' } }, description: undefined, - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); // Spec has same keys in different order @@ -202,7 +202,7 @@ describe('setupConfigBundles', () => { }, }; - mockDeleteConfigurationBundle.mockResolvedValue({ success: true }); + mockDeleteConfigurationBundle.mockResolvedValue(undefined); const result = await setupConfigBundles({ region: REGION, @@ -218,7 +218,7 @@ describe('setupConfigBundles', () => { expect(result.hasErrors).toBe(false); }); - it('should report error status when delete returns success false', async () => { + it('should report error status when delete throws', async () => { const existingBundles = { OrphanBundle: { bundleId: 'b-orphan', @@ -227,7 +227,7 @@ describe('setupConfigBundles', () => { }, }; - mockDeleteConfigurationBundle.mockResolvedValue({ success: false, error: 'Access denied' }); + mockDeleteConfigurationBundle.mockRejectedValue(new Error('Access denied')); const result = await setupConfigBundles({ region: REGION, @@ -345,7 +345,7 @@ describe('setupConfigBundles', () => { versionId: 'v-latest', components: { old: { type: 'inline', value: 'data' } }, description: undefined, - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); mockListConfigurationBundles.mockResolvedValue({ diff --git a/src/cli/operations/deploy/__tests__/preflight-container.test.ts b/src/cli/operations/deploy/__tests__/preflight-container.test.ts index 5aeb0ce86..0bf4a7af7 100644 --- a/src/cli/operations/deploy/__tests__/preflight-container.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight-container.test.ts @@ -9,6 +9,11 @@ vi.mock('node:fs', () => ({ vi.mock('../../../../lib', () => ({ DOCKERFILE_NAME: 'Dockerfile', + getDockerfilePath: (codeLocation: string, dockerfile?: string) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const p = require('node:path') as typeof import('node:path'); + return p.join(codeLocation, dockerfile ?? 'Dockerfile'); + }, resolveCodeLocation: vi.fn((codeLocation: string, configBaseDir: string) => { // eslint-disable-next-line @typescript-eslint/no-require-imports const p = require('node:path') as typeof import('node:path'); @@ -96,4 +101,27 @@ describe('validateContainerAgents', () => { expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/agent-a.*agent-b/s); }); + + it('checks for custom dockerfile name when specified', () => { + mockedExistsSync.mockReturnValue(true); + + const spec = makeSpec([ + { name: 'gpu-agent', build: 'Container', codeLocation: dir('agents/gpu'), dockerfile: 'Dockerfile.gpu' }, + ]); + + expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow(); + // Should check for Dockerfile.gpu, not the default Dockerfile + const calledPath = mockedExistsSync.mock.calls[0]?.[0] as string; + expect(calledPath).toContain('Dockerfile.gpu'); + }); + + it('throws with custom dockerfile name in error message when missing', () => { + mockedExistsSync.mockReturnValue(false); + + const spec = makeSpec([ + { name: 'gpu-agent', build: 'Container', codeLocation: dir('agents/gpu'), dockerfile: 'Dockerfile.gpu' }, + ]); + + expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/Dockerfile\.gpu not found/); + }); }); diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index 13a293c75..621174afa 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -90,7 +90,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr 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 effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'main'; const result = await updateConfigurationBundle({ region, bundleId: existingBundle.bundleId, @@ -158,7 +158,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr versionId: current.versionId, }); } else { - const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'main'; const result = await updateConfigurationBundle({ region, bundleId: existingByName.bundleId, @@ -223,15 +223,14 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr for (const [bundleName, bundleState] of Object.entries(existingBundles)) { if (!specBundleNames.has(bundleName)) { try { - const deleteResult = await deleteConfigurationBundle({ + await deleteConfigurationBundle({ region, bundleId: bundleState.bundleId, }); results.push({ bundleName, - status: deleteResult.success ? 'deleted' : 'error', - error: deleteResult.error, + status: 'deleted', }); } catch (err) { results.push({ diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index e427fdf53..4aa24e71b 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -1,4 +1,4 @@ -import { ConfigIO, DOCKERFILE_NAME, requireConfigRoot, resolveCodeLocation } from '../../../lib'; +import { ConfigIO, DOCKERFILE_NAME, getDockerfilePath, requireConfigRoot, resolveCodeLocation } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTarget } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { LocalCdkProject } from '../../cdk/local-cdk-project'; @@ -147,11 +147,11 @@ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, confi for (const agent of projectSpec.runtimes || []) { if (agent.build === 'Container') { const codeLocation = resolveCodeLocation(agent.codeLocation, configRoot); - const dockerfilePath = path.join(codeLocation, DOCKERFILE_NAME); + const dockerfilePath = getDockerfilePath(codeLocation, agent.dockerfile); if (!existsSync(dockerfilePath)) { errors.push( - `Agent "${agent.name}": Dockerfile not found at ${dockerfilePath}. Container agents require a Dockerfile.` + `Agent "${agent.name}": ${agent.dockerfile ?? DOCKERFILE_NAME} not found at ${dockerfilePath}. Container agents require a Dockerfile.` ); } } diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 8655c67ac..64aeb7256 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -380,6 +380,36 @@ describe('getDevConfig', () => { expect(config).not.toBeNull(); expect(config!.isPython).toBe(true); }); + + it('threads dockerfile from Container agent spec to DevConfig', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [ + { + name: 'ContainerAgent', + build: 'Container', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/container'), + protocol: 'HTTP', + dockerfile: 'Dockerfile.gpu', + }, + ], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + }; + + const config = getDevConfig(workingDir, project, '/test/project/agentcore'); + expect(config).not.toBeNull(); + expect(config?.dockerfile).toBe('Dockerfile.gpu'); + }); }); describe('getAgentPort', () => { diff --git a/src/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index 36b174748..fd13637bb 100644 --- a/src/cli/operations/dev/config.ts +++ b/src/cli/operations/dev/config.ts @@ -10,6 +10,7 @@ export interface DevConfig { isPython: boolean; buildType: BuildType; protocol: ProtocolMode; + dockerfile?: string; } interface DevSupportResult { @@ -140,6 +141,7 @@ export function getDevConfig( isPython: isPythonAgent(targetAgent), buildType: targetAgent.build, protocol: targetAgent.protocol ?? 'HTTP', + dockerfile: targetAgent.dockerfile, }; } diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index 3690e8ee8..1159f7bbe 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -1,4 +1,4 @@ -import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME } from '../../../lib'; +import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../../../lib'; import { getUvBuildArgs } from '../../../lib/packaging/build-args'; import { detectContainerRuntime, getStartHint } from '../../external-requirements/detect'; import { DevServer, type LogLevel, type SpawnConfig } from './dev-server'; @@ -73,9 +73,10 @@ export class ContainerDevServer extends DevServer { this.runtimeBinary = runtime.binary; // 2. Verify Dockerfile exists - const dockerfilePath = join(this.config.directory, DOCKERFILE_NAME); + const dockerfileName = this.config.dockerfile ?? DOCKERFILE_NAME; + const dockerfilePath = getDockerfilePath(this.config.directory, this.config.dockerfile); if (!existsSync(dockerfilePath)) { - onLog('error', `Dockerfile not found at ${dockerfilePath}. Container agents require a Dockerfile.`); + onLog('error', `${dockerfileName} not found at ${dockerfilePath}. Container agents require a Dockerfile.`); return false; } diff --git a/src/cli/operations/eval/batch-eval-storage.ts b/src/cli/operations/eval/batch-eval-storage.ts index fdb0f02ff..e8fd21872 100644 --- a/src/cli/operations/eval/batch-eval-storage.ts +++ b/src/cli/operations/eval/batch-eval-storage.ts @@ -1,4 +1,5 @@ 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'; @@ -13,6 +14,7 @@ export interface BatchEvalRunRecord { completedAt?: string; evaluators: string[]; results: BatchEvaluationResult[]; + evaluationResults?: EvaluationResults; } function getResultsDir(): string { @@ -38,6 +40,7 @@ export function saveBatchEvalRun(result: RunBatchEvaluationCommandResult): strin completedAt: result.completedAt, evaluators: result.results.map(r => r.evaluatorId), results: result.results, + evaluationResults: result.evaluationResults, }; writeFileSync(filePath, JSON.stringify(record, null, 2)); diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index d09e9c97b..4c33192d6 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -9,7 +9,7 @@ import { ConfigIO } from '../../../lib'; import type { DeployedState } from '../../../schema'; import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import type { GetBatchEvaluationResult } from '../../aws/agentcore-batch-evaluation'; +import type { EvaluationResults, GetBatchEvaluationResult } 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'; @@ -50,6 +50,7 @@ export interface RunBatchEvaluationCommandResult { name?: string; status?: string; results: BatchEvaluationResult[]; + evaluationResults?: EvaluationResults; startedAt?: string; completedAt?: string; logFilePath?: string; @@ -102,7 +103,6 @@ export async function runBatchEvaluationCommand( } const runtimeId = agentState.runtimeId; - const roleArn = agentState.roleArn; // 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`; @@ -130,7 +130,7 @@ export async function runBatchEvaluationCommand( logGroupNames: [runtimeLogGroup], }, }, - executionRoleArn: options.executionRoleArn ?? roleArn, + ...(options.executionRoleArn ? { executionRoleArn: options.executionRoleArn } : {}), clientToken: generateClientToken(), }; @@ -218,6 +218,7 @@ export async function runBatchEvaluationCommand( name: evalName, status: current.status, results, + evaluationResults: current.evaluationResults, startedAt: current.createdAt, completedAt: current.updatedAt ?? new Date().toISOString(), logFilePath: logger?.logFilePath, 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 12da67441..ac556ab24 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 @@ -367,5 +367,121 @@ describe('fetchGatewayToken', () => { await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow('Token request failed: 401'); }); + + it('lists available OAuth credentials in error when no match found', async () => { + const projectSpecWithOtherCred = { + ...defaultProjectSpecCustomJwt, + credentials: [ + { + authorizerType: 'OAuthCredentialProvider', + name: 'my-custom-identity', + discoveryUrl: DISCOVERY_URL, + }, + ], + }; + + const configIO = createMockConfigIO({ + projectSpec: projectSpecWithOtherCred, + }); + + await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow( + 'Available OAuth credentials: my-custom-identity' + ); + }); + + it('suggests --identity-name in error when credentials exist but none match', async () => { + const projectSpecWithOtherCred = { + ...defaultProjectSpecCustomJwt, + credentials: [ + { + authorizerType: 'OAuthCredentialProvider', + name: 'my-custom-identity', + discoveryUrl: DISCOVERY_URL, + }, + ], + }; + + const configIO = createMockConfigIO({ + projectSpec: projectSpecWithOtherCred, + }); + + await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow('--identity-name'); + }); + }); + + describe('--identity-name option', () => { + it('uses custom identity name instead of default convention', async () => { + vi.mocked(readEnvFile).mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_CUSTOM_IDENTITY_CLIENT_SECRET: 'custom-secret', + AGENTCORE_CREDENTIAL_MY_CUSTOM_IDENTITY_CLIENT_ID: 'custom-client', + }); + + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ token_endpoint: TOKEN_ENDPOINT }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: 'custom-token', expires_in: 1800 }), + } as Response); + + const projectSpecWithCustomCred = { + ...defaultProjectSpecCustomJwt, + credentials: [ + { + authorizerType: 'OAuthCredentialProvider', + name: 'my-custom-identity', + discoveryUrl: DISCOVERY_URL, + }, + ], + }; + + const configIO = createMockConfigIO({ + projectSpec: projectSpecWithCustomCred, + }); + + const result = await fetchGatewayToken('myGateway', { + configIO, + identityName: 'my-custom-identity', + }); + + expect(result).toEqual({ + url: GATEWAY_URL, + authType: 'CUSTOM_JWT', + token: 'custom-token', + expiresIn: 1800, + }); + }); + + it('falls back to default convention when identityName not provided', async () => { + vi.mocked(readEnvFile).mockResolvedValue({ + AGENTCORE_CREDENTIAL_MYGATEWAY_OAUTH_CLIENT_SECRET: 'test-secret', + AGENTCORE_CREDENTIAL_MYGATEWAY_OAUTH_CLIENT_ID: 'test-client', + }); + + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ token_endpoint: TOKEN_ENDPOINT }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: 'test-token', expires_in: 3600 }), + } as Response); + + const configIO = createMockConfigIO({ + projectSpec: defaultProjectSpecCustomJwt, + }); + + const result = await fetchGatewayToken('myGateway', { configIO }); + + expect(result).toEqual({ + url: GATEWAY_URL, + authType: 'CUSTOM_JWT', + token: 'test-token', + expiresIn: 3600, + }); + }); }); }); diff --git a/src/cli/operations/fetch-access/fetch-gateway-token.ts b/src/cli/operations/fetch-access/fetch-gateway-token.ts index 68e7a46f5..d02b4ba78 100644 --- a/src/cli/operations/fetch-access/fetch-gateway-token.ts +++ b/src/cli/operations/fetch-access/fetch-gateway-token.ts @@ -4,7 +4,7 @@ import type { TokenFetchResult } from './types'; export async function fetchGatewayToken( gatewayName: string, - options: { configIO?: ConfigIO; deployTarget?: string } = {} + options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {} ): Promise { const configIO = options.configIO ?? new ConfigIO(); @@ -71,6 +71,7 @@ export async function fetchGatewayToken( deployedState, targetName, credentials: projectSpec.credentials, + credentialName: options.identityName, }); return { diff --git a/src/cli/operations/fetch-access/fetch-runtime-token.ts b/src/cli/operations/fetch-access/fetch-runtime-token.ts index 0b9235356..87b0017ec 100644 --- a/src/cli/operations/fetch-access/fetch-runtime-token.ts +++ b/src/cli/operations/fetch-access/fetch-runtime-token.ts @@ -12,7 +12,10 @@ import type { OAuthTokenResult } from './oauth-token'; * Returns true only if the managed OAuth credential exists in the project * spec AND the client secret is available in .env.local. */ -export async function canFetchRuntimeToken(agentName: string, options: { configIO?: ConfigIO } = {}): Promise { +export async function canFetchRuntimeToken( + agentName: string, + options: { configIO?: ConfigIO; identityName?: string } = {} +): Promise { try { const configIO = options.configIO ?? new ConfigIO(); const projectSpec = await configIO.readProjectSpec(); @@ -21,7 +24,7 @@ export async function canFetchRuntimeToken(agentName: string, options: { configI if (!agentSpec?.authorizerType || agentSpec.authorizerType !== 'CUSTOM_JWT') return false; if (!agentSpec.authorizerConfiguration?.customJwtAuthorizer) return false; - const credName = computeManagedOAuthCredentialName(agentName); + const credName = options.identityName ?? computeManagedOAuthCredentialName(agentName); const hasCredential = projectSpec.credentials.some( c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName ); @@ -43,7 +46,7 @@ export async function canFetchRuntimeToken(agentName: string, options: { configI */ export async function fetchRuntimeToken( agentName: string, - options: { configIO?: ConfigIO; deployTarget?: string } = {} + options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {} ): Promise { const configIO = options.configIO ?? new ConfigIO(); @@ -80,5 +83,6 @@ export async function fetchRuntimeToken( deployedState, targetName, credentials: projectSpec.credentials, + credentialName: options.identityName, }); } diff --git a/src/cli/operations/fetch-access/oauth-token.ts b/src/cli/operations/fetch-access/oauth-token.ts index 6b18f1461..22620eb97 100644 --- a/src/cli/operations/fetch-access/oauth-token.ts +++ b/src/cli/operations/fetch-access/oauth-token.ts @@ -31,17 +31,24 @@ export async function fetchOAuthToken(opts: { targetName: string; /** Project credentials list */ credentials: { authorizerType: string; name: string }[]; + /** Optional explicit credential name. When omitted, defaults to `-oauth`. */ + credentialName?: string; }): Promise { const { resourceName, jwtConfig, deployedState, targetName, credentials } = opts; - const credName = computeManagedOAuthCredentialName(resourceName); + const credName = opts.credentialName ?? computeManagedOAuthCredentialName(resourceName); // Validate credential exists in project spec const credential = credentials.find(c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName); if (!credential) { + const availableOAuth = credentials.filter(c => c.authorizerType === 'OAuthCredentialProvider').map(c => c.name); + const availableHint = + availableOAuth.length > 0 + ? ` Available OAuth credentials: ${availableOAuth.join(', ')}. Use --identity-name to specify one.` + : ''; throw new Error( - `No managed OAuth credential found for '${resourceName}'. Expected credential '${credName}'. ` + - `Re-create the resource with --client-id and --client-secret.` + `No managed OAuth credential found for '${resourceName}'. Expected credential '${credName}'.${availableHint}` + + (availableOAuth.length === 0 ? ` Re-create the resource with --client-id and --client-secret.` : '') ); } diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index a724d2add..c40f9d50a 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -219,7 +219,7 @@ export class ConfigBundlePrimitive extends BasePrimitive> { + async add(options: AddEvaluatorOptions): Promise> { try { const evaluator = await this.createEvaluator(options); + + // Scaffold code for managed code-based evaluators + if (options.config.codeBased?.managed) { + const configRoot = findConfigRoot()!; + const projectRoot = dirname(configRoot); + const codeLocation = options.config.codeBased.managed.codeLocation; + const targetDir = join(projectRoot, codeLocation); + await renderCodeBasedEvaluatorTemplate(options.name, targetDir); + return { success: true, evaluatorName: evaluator.name, codePath: codeLocation }; + } + return { success: true, evaluatorName: evaluator.name }; } catch (err) { return { success: false, error: getErrorMessage(err) }; @@ -59,6 +77,17 @@ export class EvaluatorPrimitive extends BasePrimitive c.evaluators.includes(evaluatorName)); @@ -86,6 +116,18 @@ export class EvaluatorPrimitive extends BasePrimitive e.name !== evaluatorName), @@ -97,7 +139,7 @@ export class EvaluatorPrimitive extends BasePrimitive { @@ -124,17 +166,17 @@ export class EvaluatorPrimitive extends BasePrimitive', 'Evaluator name [non-interactive]') - .option('--level ', 'Evaluation level: SESSION, TRACE, TOOL_CALL [non-interactive]') - .option('--model ', 'Bedrock model ID for LLM-as-a-Judge [non-interactive]') + .option('--name ', 'Evaluator name') + .option('--level ', 'Evaluation level: SESSION, TRACE, TOOL_CALL') + .option('--type ', 'Evaluator type: llm-as-a-judge (default) or code-based') + .option('--model ', '[LLM] Bedrock model ID for LLM-as-a-Judge') .option( '--instructions ', - 'Evaluation prompt instructions (must include level-appropriate placeholders, e.g. {context}) [non-interactive]' - ) - .option( - '--rating-scale ', - `Rating scale preset: ${presetIds.join(', ')} (default: 1-5-quality) [non-interactive]` + '[LLM] Evaluation prompt instructions (must include level-appropriate placeholders, e.g. {context})' ) + .option('--rating-scale ', `[LLM] Rating scale preset: ${presetIds.join(', ')} (default: 1-5-quality)`) + .option('--lambda-arn ', '[Code-based] Existing Lambda function ARN (external)') + .option('--timeout ', '[Code-based] Lambda timeout in seconds, 1-300 (default: 60)') .option( '--config ', 'Path to evaluator config JSON file (overrides --model, --instructions, --rating-scale) [non-interactive]' @@ -144,9 +186,12 @@ export class EvaluatorPrimitive extends BasePrimitive { @@ -170,21 +215,40 @@ export class EvaluatorPrimitive extends BasePrimitive `{${p}}`).join(', '); @@ -194,21 +258,18 @@ export class EvaluatorPrimitive extends BasePrimitive['ratingScale']; const scaleInput = cliOptions.ratingScale ?? '1-5-quality'; const preset = RATING_SCALE_PRESETS.find(p => p.id === scaleInput); if (preset) { ratingScale = preset.ratingScale; } else { - // Try parsing as custom format: "1:Poor:Fails, 2:Fair:Partially meets" or "Pass:Meets, Fail:Does not" const isNumerical = /^\d/.test(scaleInput.trim()); const parsed = parseCustomRatingScale(scaleInput, isNumerical ? 'numerical' : 'categorical'); if (!parsed.success) { @@ -239,7 +300,16 @@ export class EvaluatorPrimitive extends BasePrimitive { const project = await this.readProjectSpec(); diff --git a/src/cli/primitives/OnlineEvalConfigPrimitive.ts b/src/cli/primitives/OnlineEvalConfigPrimitive.ts index e04df94d4..03687047e 100644 --- a/src/cli/primitives/OnlineEvalConfigPrimitive.ts +++ b/src/cli/primitives/OnlineEvalConfigPrimitive.ts @@ -210,6 +210,18 @@ export class OnlineEvalConfigPrimitive 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/__tests__/EvaluatorPrimitive.test.ts b/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts index fc6d244df..b41545d20 100644 --- a/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts +++ b/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts @@ -26,6 +26,15 @@ const validConfig: EvaluatorConfig = { }, }; +function makeEvaluator(name: string, config?: EvaluatorConfig) { + return { + name, + type: 'CustomEvaluator', + level: 'SESSION', + config: config ?? validConfig, + }; +} + function makeProject( evaluators: { name: string }[] = [], onlineEvalConfigs: { name: string; evaluators: string[] }[] = [] @@ -37,7 +46,7 @@ function makeProject( runtimes: [], memories: [], credentials: [], - evaluators, + evaluators: evaluators.map(e => ('config' in e ? e : makeEvaluator(e.name))), onlineEvalConfigs, }; } diff --git a/src/cli/templates/BaseRenderer.ts b/src/cli/templates/BaseRenderer.ts index 7de56b01a..325aeb392 100644 --- a/src/cli/templates/BaseRenderer.ts +++ b/src/cli/templates/BaseRenderer.ts @@ -71,7 +71,8 @@ export abstract class BaseRenderer { const containerTemplateDir = path.join(this.baseTemplateDir, 'container', language); if (existsSync(containerTemplateDir)) { - await copyAndRenderDir(containerTemplateDir, projectDir, { ...templateData, entrypoint: 'main' }); + const exclude = this.config.dockerfile ? new Set(['Dockerfile']) : undefined; + await copyAndRenderDir(containerTemplateDir, projectDir, { ...templateData, entrypoint: 'main' }, { exclude }); } } } diff --git a/src/cli/templates/EvaluatorRenderer.ts b/src/cli/templates/EvaluatorRenderer.ts new file mode 100644 index 000000000..6b2f22c24 --- /dev/null +++ b/src/cli/templates/EvaluatorRenderer.ts @@ -0,0 +1,12 @@ +import { copyAndRenderDir } from './render'; +import { getTemplatePath } from './templateRoot'; + +/** + * Renders a code-based evaluator template to the specified output directory. + * @param evaluatorName - Name of the evaluator (used for {{ Name }} substitution) + * @param outputDir - Target directory for the evaluator code + */ +export async function renderCodeBasedEvaluatorTemplate(evaluatorName: string, outputDir: string): Promise { + const templateDir = getTemplatePath('evaluators', 'python-lambda'); + await copyAndRenderDir(templateDir, outputDir, { Name: evaluatorName }); +} diff --git a/src/cli/templates/render.ts b/src/cli/templates/render.ts index e56a84907..55c228555 100644 --- a/src/cli/templates/render.ts +++ b/src/cli/templates/render.ts @@ -47,13 +47,19 @@ export async function copyDir(src: string, dest: string): Promise { /** * Recursively copies a directory, rendering Handlebars templates. */ -export async function copyAndRenderDir(src: string, dest: string, data: T): Promise { +export async function copyAndRenderDir( + src: string, + dest: string, + data: T, + options?: { exclude?: Set } +): Promise { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { - const srcPath = path.join(src, entry.name); const destName = resolveTemplateName(entry.name); + if (options?.exclude?.has(destName)) continue; + const srcPath = path.join(src, entry.name); const destPath = path.join(dest, destName); if (entry.isDirectory()) { diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index 337cacf4a..094af9782 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -66,4 +66,6 @@ export interface AgentRenderConfig { gatewayAuthTypes: string[]; /** Protocol (HTTP, MCP, A2A). Defaults to HTTP. */ protocol?: ProtocolMode; + /** Custom Dockerfile name — when set, the template Dockerfile is not scaffolded */ + dockerfile?: string; } diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 41b1377d1..932cbc4eb 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -12,6 +12,7 @@ import { DevScreen } from './screens/dev/DevScreen'; import { EvalHubScreen, EvalScreen } from './screens/eval'; import { FetchAccessScreen } from './screens/fetch-access'; import { HelpScreen, HomeScreen } from './screens/home'; +import { ImportFlow } from './screens/import'; import { InvokeScreen } from './screens/invoke'; import { OnlineEvalDashboard } from './screens/online-eval'; import { PackageScreen } from './screens/package'; @@ -52,6 +53,7 @@ type Route = | { name: 'package' } | { name: 'update' } | { name: 'config-bundle' } + | { name: 'import' } | { name: 'cli-only'; commandId: string }; // Commands that don't require being at the project root @@ -121,6 +123,12 @@ function AppContent() { setRoute({ name: 'validate' }); } else if (id === 'package') { setRoute({ name: 'package' }); + } else if (id === 'import') { + if (!projectExists() && route.name === 'help') { + setHelpNotice(); + return; + } + setRoute({ name: 'import' }); } else if (id === 'update') { setRoute({ name: 'update' }); } else if (id === 'config-bundle') { @@ -256,7 +264,6 @@ function AppContent() { onSelect={view => { if (view === 'run-recommendation') setRoute({ name: 'recommend', from: 'recommendations-hub' }); if (view === 'recommendation-history') setRoute({ name: 'recommendation-history' }); - if (view === 'list-recommendations') setRoute({ name: 'help' }); // TODO: wire to list recommendations TUI }} onExit={() => setRoute({ name: 'help' })} /> @@ -292,6 +299,15 @@ function AppContent() { return setRoute({ name: 'help' })} />; } + if (route.name === 'import') { + return ( + setRoute({ name: 'help' })} + onNavigate={command => setRoute({ name: command } as Route)} + /> + ); + } + if (route.name === 'update') { return setRoute({ name: 'help' })} />; } diff --git a/src/cli/tui/components/PathInput.tsx b/src/cli/tui/components/PathInput.tsx index aec89ae8e..ebb16f956 100644 --- a/src/cli/tui/components/PathInput.tsx +++ b/src/cli/tui/components/PathInput.tsx @@ -17,6 +17,12 @@ interface PathInputProps { maxVisibleItems?: number; /** Allow the final path segment to not exist (for create workflows). Parent directory must still exist. */ allowCreate?: boolean; + /** Show hidden files (dotfiles) in completions (default: false) */ + showHidden?: boolean; + /** Allow empty input (user presses Enter without selecting a file) */ + allowEmpty?: boolean; + /** Message shown when user submits empty input (only if allowEmpty is true) */ + emptyHelpText?: string; } interface CompletionItem { @@ -37,7 +43,7 @@ function parsePath(input: string, basePath: string): { dir: string; prefix: stri return { dir, prefix }; } -function getCompletions(input: string, basePath: string, pathType: PathType): CompletionItem[] { +function getCompletions(input: string, basePath: string, pathType: PathType, showHidden = false): CompletionItem[] { try { const { dir, prefix } = parsePath(input, basePath); const entries = readdirSync(dir, { withFileTypes: true }); @@ -45,7 +51,7 @@ function getCompletions(input: string, basePath: string, pathType: PathType): Co const items = entries .filter(entry => { if (!entry.name.toLowerCase().startsWith(prefix.toLowerCase())) return false; - if (entry.name.startsWith('.')) return false; + if (!showHidden && entry.name.startsWith('.')) return false; if (pathType === 'directory' && !entry.isDirectory()) return false; return true; }) @@ -130,6 +136,9 @@ export function PathInput({ pathType = 'file', maxVisibleItems = 8, allowCreate = false, + showHidden = false, + allowEmpty = false, + emptyHelpText, }: PathInputProps) { const [value, setValue] = useState(initialValue); const [cursor, setCursor] = useState(initialValue.length); @@ -137,7 +146,7 @@ export function PathInput({ const [error, setError] = useState(null); // Get live completions based on current value - const matches = getCompletions(value, basePath, pathType); + const matches = getCompletions(value, basePath, pathType, showHidden); // Calculate viewport for scrolling const totalItems = matches.length; @@ -204,6 +213,10 @@ export function PathInput({ if (key.return) { const trimmed = value.trim(); if (!trimmed) { + if (allowEmpty) { + onSubmit(''); + return; + } setError('Please enter a path'); return; } @@ -319,8 +332,9 @@ export function PathInput({ )} {/* Help text */} - + ↑↓ move → open ← back Enter submit Esc cancel + {allowEmpty && emptyHelpText && !value && {emptyHelpText}} ); diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 0f14c81bf..22e9774e4 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -73,6 +73,7 @@ function ResourceRow({ statusColor, deploymentState, identifier, + invocationUrl, }: { icon: string; color: string; @@ -82,6 +83,7 @@ function ResourceRow({ statusColor?: string; deploymentState?: ResourceStatusEntry['deploymentState']; identifier?: string; + invocationUrl?: string; }) { const badge = deploymentState ? getDeploymentBadge(deploymentState) : undefined; @@ -99,6 +101,11 @@ function ResourceRow({ {' '}ID: {identifier} )} + {invocationUrl && ( + + {' '}URL: {invocationUrl} + + )} ); } @@ -184,6 +191,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res statusColor={runtimeStatusColor} deploymentState={rsEntry?.deploymentState} identifier={rsEntry?.identifier} + invocationUrl={rsEntry?.invocationUrl} /> ); })} diff --git a/src/cli/tui/components/TextInput.tsx b/src/cli/tui/components/TextInput.tsx index c9b5cd2ba..b72f0b662 100644 --- a/src/cli/tui/components/TextInput.tsx +++ b/src/cli/tui/components/TextInput.tsx @@ -24,6 +24,10 @@ interface TextInputProps { hideArrow?: boolean; /** Allow text to wrap across multiple lines instead of truncating (default false) */ expandable?: boolean; + /** Called when the input value changes, with a setValue callback to transform input */ + onChange?: (value: string, setValue: (v: string) => void) => void; + /** Called when backspace is pressed on an empty input */ + onBackspaceEmpty?: () => void; /** Called when up arrow is pressed */ onUpArrow?: () => void; /** Called when down arrow is pressed */ @@ -62,14 +66,18 @@ export function TextInput({ mask, hideArrow = false, expandable = false, + onChange: onChangeProp, + onBackspaceEmpty, onUpArrow, onDownArrow, }: TextInputProps) { const [showError, setShowError] = useState(false); const { stdout } = useStdout(); - const { value, cursor } = useTextInput({ + const { value, cursor, setValue } = useTextInput({ initialValue, + onChange: onChangeProp ? (v: string) => onChangeProp(v, setValue) : undefined, + onBackspaceEmpty, onUpArrow, onDownArrow, onSubmit: val => { diff --git a/src/cli/tui/components/__tests__/PathInput.test.tsx b/src/cli/tui/components/__tests__/PathInput.test.tsx index f673cc60c..0629c6ebf 100644 --- a/src/cli/tui/components/__tests__/PathInput.test.tsx +++ b/src/cli/tui/components/__tests__/PathInput.test.tsx @@ -189,6 +189,23 @@ describe('PathInput', () => { expect(frame).not.toContain('.gitignore'); }); + it('shows dotfiles when showHidden is true', () => { + mockReaddirSync.mockReturnValue([ + makeDirent('.hidden', true), + makeDirent('.bedrock_agentcore.yaml', false), + makeDirent('visible', true), + ]); + + const { lastFrame } = render( + + ); + + const frame = lastFrame()!; + expect(frame).toContain('visible/'); + expect(frame).toContain('.hidden'); + expect(frame).toContain('.bedrock_agentcore.yaml'); + }); + it('navigates dropdown with arrow keys', async () => { mockReaddirSync.mockReturnValue([makeDirent('alpha', true), makeDirent('beta', true), makeDirent('gamma', true)]); diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index beae987e8..4e6eb774b 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -31,7 +31,7 @@ export const COMMAND_DESCRIPTIONS = { /** Main program description */ program: 'Build and deploy Agentic AI applications on AgentCore', /** Command descriptions */ - add: 'Add resources (agent, evaluator, online-eval, memory, credential, target)', + 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.', @@ -41,15 +41,15 @@ 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.', recommend: 'Run optimization recommendations for system prompts and tool descriptions.', - recommendations: 'Manage optimization recommendations (history).', - run: 'Run on-demand evaluation. Supports --agent-arn for agents outside the project.', + recommendations: 'View recommendation history from past runs.', + run: 'Run evaluations, batch evaluations, or optimization recommendations.', stop: 'Stop a running batch evaluation.', - import: 'Import resources from a Bedrock AgentCore Starter Toolkit project.', + import: 'Import a runtime, memory, or starter toolkit into this project. [experimental]', update: 'Check for and install CLI updates', validate: 'Validate agentcore/ config files.', 'config-bundle': 'Manage configuration bundle versions and diffs.', @@ -65,7 +65,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', + ], + }, + 'config-bundle': { + description: 'View configuration bundle version history and compare versions.', + examples: [ + 'agentcore config-bundle versions --bundle MyBundle', + 'agentcore config-bundle versions --bundle MyBundle --latest-per-branch', + 'agentcore config-bundle diff --bundle MyBundle --from v1-id --to v2-id', + ], + }, + stop: { + description: 'Stop a running batch evaluation.', + examples: [ + 'agentcore stop batch-evaluation -i ', + 'agentcore stop batch-evaluation -i --json', + ], + }, + evals: { + description: 'View saved eval and batch eval results from past runs.', + examples: [ + 'agentcore evals history', + 'agentcore evals history -r MyAgent --limit 5', + 'agentcore evals history --json', + ], + }, }; diff --git a/src/cli/tui/hooks/useCreateEvaluator.ts b/src/cli/tui/hooks/useCreateEvaluator.ts index bf3015bd8..6e1d8f052 100644 --- a/src/cli/tui/hooks/useCreateEvaluator.ts +++ b/src/cli/tui/hooks/useCreateEvaluator.ts @@ -25,7 +25,7 @@ export function useCreateEvaluator() { throw new Error(addResult.error ?? 'Failed to create evaluator'); } setStatus({ state: 'success' }); - return { ok: true as const, evaluatorName: config.name }; + return { ok: true as const, evaluatorName: config.name, codePath: addResult.codePath }; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create evaluator.'; setStatus({ state: 'error', error: message }); diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index ee13a5136..b466e816a 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -1,5 +1,6 @@ import { findConfigRoot, readEnvFile } from '../../../lib'; import type { AgentCoreProjectSpec, ProtocolMode } from '../../../schema'; +import { detectContainerRuntime } from '../../external-requirements'; import { DevLogger } from '../../logging/dev-logger'; import { type A2AAgentCard, @@ -23,6 +24,7 @@ import { } from '../../operations/dev'; import { getGatewayEnvVars } from '../../operations/dev/gateway-env.js'; import { formatMcpToolList } from '../../operations/dev/utils'; +import { spawn } from 'child_process'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; type ServerStatus = 'starting' | 'running' | 'error' | 'stopped'; @@ -37,6 +39,7 @@ export interface ConversationMessage { content: string; isError?: boolean; isHint?: boolean; + isExec?: boolean; } const MAX_LOG_ENTRIES = 50; @@ -385,6 +388,98 @@ export function useDevServer(options: { } }; + const runSpawnCommand = async ( + spawnBinary: string, + spawnArgs: string[], + spawnOpts: { cwd?: string; env?: NodeJS.ProcessEnv }, + label: string, + prefix: string, + command: string, + onStart?: () => void + ) => { + setConversation(prev => [...prev, { role: 'user', content: `${prefix} ${command}`, isExec: true }]); + setStreamingResponse(null); + setIsStreaming(true); + onStart?.(); + + let output = ''; + + try { + await new Promise((resolve, reject) => { + const child = spawn(spawnBinary, spawnArgs, { stdio: 'pipe', ...spawnOpts }); + + child.stdout?.on('data', (data: Buffer) => { + output += data.toString(); + setStreamingResponse(output); + }); + + child.stderr?.on('data', (data: Buffer) => { + output += data.toString(); + setStreamingResponse(output); + }); + + child.on('error', reject); + child.on('close', code => { + if (code !== 0 && code !== null) { + output += `\n[exit code: ${code}]`; + setStreamingResponse(output); + } + resolve(); + }); + }); + + setConversation(prev => [...prev, { role: 'assistant', content: output || '(no output)', isExec: true }]); + setStreamingResponse(null); + loggerRef.current?.log('system', `${label}: ${command}`); + loggerRef.current?.log('response', output); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + addLog('error', `${label} failed: ${errMsg}`); + setConversation(prev => [...prev, { role: 'assistant', content: `Error: ${errMsg}`, isError: true }]); + setStreamingResponse(null); + } finally { + setIsStreaming(false); + } + }; + + const execCommand = async (command: string, onStart?: () => void) => { + await runSpawnCommand( + 'bash', + ['-c', command], + { cwd: options.workingDir, env: { ...process.env, ...envVars } }, + 'exec', + '!', + command, + onStart + ); + }; + + const execInContainer = async (command: string, onStart?: () => void) => { + const containerName = `agentcore-dev-${config?.agentName ?? ''}`.toLowerCase(); + const detection = await detectContainerRuntime(); + if (!detection.runtime) { + addLog('error', 'No container runtime found (docker, podman, or finch required)'); + setConversation(prev => [ + ...prev, + { + role: 'assistant', + content: 'Error: No container runtime found (docker, podman, or finch required)', + isError: true, + }, + ]); + return; + } + await runSpawnCommand( + detection.runtime.binary, + ['exec', containerName, 'bash', '-c', command], + {}, + 'container exec', + '!!', + command, + onStart + ); + }; + const clearLogs = () => { setLogs([]); logsRef.current = []; @@ -426,6 +521,9 @@ export function useDevServer(options: { configLoaded, actualPort, invoke, + execCommand, + execInContainer, + isContainer: config?.buildType === 'Container', clearLogs, clearConversation, restart, @@ -433,7 +531,6 @@ export function useDevServer(options: { logFilePath: loggerRef.current?.getRelativeLogPath(), hasMemory: (project?.memories?.length ?? 0) > 0, hasVpc: project?.runtimes.find(a => a.name === config?.agentName)?.networkMode === 'VPC', - modelProvider: undefined, protocol, mcpTools, fetchMcpTools, diff --git a/src/cli/tui/hooks/useTextInput.ts b/src/cli/tui/hooks/useTextInput.ts index 06968bbbe..e1a22238e 100644 --- a/src/cli/tui/hooks/useTextInput.ts +++ b/src/cli/tui/hooks/useTextInput.ts @@ -25,6 +25,8 @@ export interface UseTextInputOptions { onCancel?: () => void; /** Called when value changes */ onChange?: (value: string) => void; + /** Called when backspace is pressed on an empty input */ + onBackspaceEmpty?: () => void; /** Called when up arrow is pressed */ onUpArrow?: () => void; /** Called when down arrow is pressed */ @@ -62,6 +64,7 @@ export function useTextInput({ onSubmit, onCancel, onChange, + onBackspaceEmpty, onUpArrow, onDownArrow, isActive = true, @@ -102,8 +105,14 @@ export function useTextInput({ // Backspace variants if (key.backspace || key.delete) { + if (state.cursor === 0 && state.text.length === 0) { + onBackspaceEmpty?.(); + return; + } setState(prev => { - if (prev.cursor === 0) return prev; + if (prev.cursor === 0) { + return prev; + } // Cmd+Backspace: delete to start if (key.meta) { return { text: prev.text.slice(prev.cursor), cursor: 0 }; diff --git a/src/cli/tui/screens/agent/AddAgentScreen.tsx b/src/cli/tui/screens/agent/AddAgentScreen.tsx index 2b40dd28f..0e54613c9 100644 --- a/src/cli/tui/screens/agent/AddAgentScreen.tsx +++ b/src/cli/tui/screens/agent/AddAgentScreen.tsx @@ -17,19 +17,22 @@ import { ConfirmReview, Cursor, Panel, + PathInput, Screen, StepIndicator, TextInput, + WizardMultiSelect, WizardSelect, } from '../../components'; import type { SelectableItem } from '../../components'; import { JwtConfigInput, useJwtConfigFlow } from '../../components/jwt-config'; import { HELP_TEXT } from '../../constants'; -import { useListNavigation, useProject } from '../../hooks'; +import { useListNavigation, useMultiSelectNavigation, useProject } from '../../hooks'; import { generateUniqueName } from '../../utils'; import { BUILD_TYPE_OPTIONS, GenerateWizardUI, getWizardHelpText, useGenerateWizard } from '../generate'; import type { BuildType, MemoryOption } from '../generate'; -import { ADVANCED_OPTIONS, MEMORY_OPTIONS } from '../generate/types'; +import type { AdvancedSettingId } from '../generate/types'; +import { ADVANCED_SETTING_OPTIONS, MEMORY_OPTIONS } from '../generate/types'; import type { AddAgentConfig, AddAgentStep, AgentType } from './types'; import { ADD_AGENT_STEP_LABELS, @@ -42,6 +45,7 @@ import { } from './types'; import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; +import { basename, resolve } from 'path'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; // Helper to get provider display name and env var name from ModelProvider @@ -70,6 +74,7 @@ type InitialStep = 'name' | 'agentType'; type ByoStep = | 'codeLocation' | 'buildType' + | 'dockerfile' | 'modelProvider' | 'apiKey' | 'advanced' @@ -84,12 +89,52 @@ type ByoStep = | 'confirm'; const INITIAL_STEPS: InitialStep[] = ['name', 'agentType']; -const ADVANCED_ITEMS: SelectableItem[] = ADVANCED_OPTIONS.map(o => ({ - id: o.id, - title: o.title, - description: o.description, -})); -const BYO_STEPS: ByoStep[] = ['codeLocation', 'buildType', 'modelProvider', 'apiKey', 'advanced', 'confirm']; +const BYO_BASE_STEPS: ByoStep[] = ['codeLocation', 'buildType', 'modelProvider', 'apiKey', 'advanced', 'confirm']; + +export interface ComputeByoStepsInput { + modelProvider: string; + buildType: string; + networkMode: string; + authorizerType: string; + advancedSettings: Set; +} + +/** Pure function to compute BYO wizard steps from config. Exported for testing. */ +export function computeByoSteps(input: ComputeByoStepsInput): ByoStep[] { + let steps = [...BYO_BASE_STEPS]; + if (input.modelProvider === 'Bedrock') { + steps = steps.filter(s => s !== 'apiKey'); + } + if (input.advancedSettings.size > 0) { + const advancedIndex = steps.indexOf('advanced'); + const afterAdvanced = advancedIndex + 1; + const subSteps: ByoStep[] = []; + if (input.advancedSettings.has('dockerfile') && input.buildType === 'Container') { + subSteps.push('dockerfile'); + } + if (input.advancedSettings.has('network')) { + subSteps.push('networkMode'); + if (input.networkMode === 'VPC') { + subSteps.push('subnets', 'securityGroups'); + } + } + if (input.advancedSettings.has('headers')) { + subSteps.push('requestHeaderAllowlist'); + } + if (input.advancedSettings.has('auth')) { + subSteps.push('authorizerType'); + } + if (input.advancedSettings.has('lifecycle')) { + subSteps.push('idleTimeout', 'maxLifetime'); + } + steps = [...steps.slice(0, afterAdvanced), ...subSteps, ...steps.slice(afterAdvanced)]; + } + if (input.authorizerType === 'CUSTOM_JWT' && steps.includes('authorizerType')) { + const authIndex = steps.indexOf('authorizerType'); + steps = [...steps.slice(0, authIndex + 1), 'jwtConfig', ...steps.slice(authIndex + 1)]; + } + return steps; +} type ImportStep = | 'region' @@ -126,6 +171,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg codeLocation: '', entrypoint: DEFAULT_ENTRYPOINT, buildType: 'CodeZip' as BuildType, + dockerfile: '' as string, modelProvider: 'Bedrock' as ModelProvider, apiKey: undefined as string | undefined, networkMode: 'PUBLIC' as NetworkMode, @@ -135,7 +181,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg idleTimeout: '' as string, maxLifetime: '' as string, }); - const [byoAdvancedSelected, setByoAdvancedSelected] = useState(false); + const [byoAdvancedSettings, setByoAdvancedSettings] = useState>(new Set()); const [byoAuthorizerType, setByoAuthorizerType] = useState('AWS_IAM'); const [byoJwtConfig, setByoJwtConfig] = useState(undefined); @@ -237,6 +283,10 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg entrypoint: 'main.py', language: generateWizard.config.language, buildType: generateWizard.config.buildType, + ...(generateWizard.config.buildType === 'Container' && + generateWizard.config.dockerfile && { + dockerfile: generateWizard.config.dockerfile, + }), protocol: generateWizard.config.protocol, framework: generateWizard.config.sdk, modelProvider: generateWizard.config.modelProvider, @@ -275,37 +325,50 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg // BYO Path // ───────────────────────────────────────────────────────────────────────────── - // BYO steps filtering (remove apiKey for Bedrock, subnets/securityGroups for non-VPC, jwtConfig for non-CUSTOM_JWT) - const byoSteps = useMemo(() => { - let steps = [...BYO_STEPS]; - if (byoConfig.modelProvider === 'Bedrock') { - steps = steps.filter(s => s !== 'apiKey'); - } - if (byoAdvancedSelected) { - const advancedIndex = steps.indexOf('advanced'); - const afterAdvanced = advancedIndex + 1; - const networkSteps: ByoStep[] = - byoConfig.networkMode === 'VPC' ? ['networkMode', 'subnets', 'securityGroups'] : ['networkMode']; - steps = [ - ...steps.slice(0, afterAdvanced), - ...networkSteps, - 'requestHeaderAllowlist', - 'authorizerType', - 'idleTimeout', - 'maxLifetime', - ...steps.slice(afterAdvanced), - ]; - } - // Add jwtConfig step after authorizerType when CUSTOM_JWT is selected - if (byoAuthorizerType === 'CUSTOM_JWT') { - const authIndex = steps.indexOf('authorizerType'); - steps = [...steps.slice(0, authIndex + 1), 'jwtConfig', ...steps.slice(authIndex + 1)]; - } - return steps; - }, [byoConfig.modelProvider, byoConfig.networkMode, byoAdvancedSelected, byoAuthorizerType]); + // BYO steps filtering (apiKey for Bedrock, advanced sub-steps based on multi-select, jwtConfig for CUSTOM_JWT) + const byoAdvancedActive = byoAdvancedSettings.size > 0; + const byoSteps = useMemo( + () => + computeByoSteps({ + modelProvider: byoConfig.modelProvider, + buildType: byoConfig.buildType, + networkMode: byoConfig.networkMode, + authorizerType: byoAuthorizerType, + advancedSettings: byoAdvancedSettings, + }), + [ + byoConfig.buildType, + byoConfig.modelProvider, + byoConfig.networkMode, + byoAdvancedActive, + byoAdvancedSettings, + byoAuthorizerType, + ] + ); const byoCurrentIndex = byoSteps.indexOf(byoStep); + /** Navigate to the next step after the given step in the BYO steps array */ + const goToNextByoStep = useCallback( + (afterStep: ByoStep) => { + const idx = byoSteps.indexOf(afterStep); + const next = idx >= 0 ? byoSteps[idx + 1] : undefined; + setByoStep(next ?? 'confirm'); + }, + [byoSteps] + ); + + // Advanced multi-select items — filter out dockerfile when not a Container build + const byoAdvancedItems: SelectableItem[] = useMemo( + () => + ADVANCED_SETTING_OPTIONS.filter(o => o.id !== 'dockerfile' || byoConfig.buildType === 'Container').map(o => ({ + id: o.id, + title: o.title, + description: o.description, + })), + [byoConfig.buildType] + ); + // BYO build type options const buildTypeItems: SelectableItem[] = useMemo( () => @@ -350,6 +413,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg entrypoint: byoConfig.entrypoint, language: 'Python', // Default - not used for BYO agents buildType: byoConfig.buildType, + ...(byoConfig.buildType === 'Container' && byoConfig.dockerfile && { dockerfile: byoConfig.dockerfile }), protocol: 'HTTP', // Default for BYO agents framework: 'Strands', // Default - not used for BYO agents modelProvider: byoConfig.modelProvider, @@ -371,7 +435,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg const buildTypeNav = useListNavigation({ items: buildTypeItems, onSelect: item => { - setByoConfig(c => ({ ...c, buildType: item.id as BuildType })); + const build = item.id as BuildType; + setByoConfig(c => ({ ...c, buildType: build, dockerfile: '' })); setByoStep('modelProvider'); }, onExit: handleByoBack, @@ -404,27 +469,49 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg [] ); - const advancedNav = useListNavigation({ - items: ADVANCED_ITEMS, - onSelect: item => { - if (item.id === 'yes') { - setByoAdvancedSelected(true); - setByoStep('networkMode'); - } else { - setByoAdvancedSelected(false); + const advancedNav = useMultiSelectNavigation({ + items: byoAdvancedItems, + getId: item => item.id, + onConfirm: selectedIds => { + const selected = new Set(selectedIds as AdvancedSettingId[]); + setByoAdvancedSettings(selected); + if (selected.size === 0) { + // No advanced settings — reset defaults and go to confirm setByoConfig(c => ({ ...c, + dockerfile: '', networkMode: 'PUBLIC' as NetworkMode, subnets: '', securityGroups: '', + requestHeaderAllowlist: '', idleTimeout: '', maxLifetime: '', })); + setByoAuthorizerType('AWS_IAM'); + setByoJwtConfig(undefined); setByoStep('confirm'); + } else { + // Navigate to first advanced sub-step (steps memo hasn't updated yet) + setTimeout(() => { + if (selected.has('dockerfile') && byoConfig.buildType === 'Container') { + setByoStep('dockerfile'); + } else if (selected.has('network')) { + setByoStep('networkMode'); + } else if (selected.has('headers')) { + setByoStep('requestHeaderAllowlist'); + } else if (selected.has('auth')) { + setByoStep('authorizerType'); + } else if (selected.has('lifecycle')) { + setByoStep('idleTimeout'); + } else { + setByoStep('confirm'); + } + }, 0); } }, onExit: handleByoBack, isActive: isByoPath && byoStep === 'advanced', + requireSelection: false, }); const networkModeNav = useListNavigation({ @@ -435,7 +522,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg if (mode === 'VPC') { setByoStep('subnets'); } else { - setByoStep('requestHeaderAllowlist'); + // Skip subnets/securityGroups — go to next step after networkMode + setTimeout(() => goToNextByoStep('networkMode'), 0); } }, onExit: handleByoBack, @@ -457,7 +545,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg setByoStep('jwtConfig'); } else { setByoJwtConfig(undefined); - setByoStep('confirm'); + setTimeout(() => goToNextByoStep('authorizerType'), 0); } }, onExit: handleByoBack, @@ -468,7 +556,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg const byoJwtFlow = useJwtConfigFlow({ onComplete: jwtConfig => { setByoJwtConfig(jwtConfig); - setByoStep('confirm'); + setTimeout(() => goToNextByoStep('jwtConfig'), 0); }, onBack: () => { setByoStep('authorizerType'); @@ -716,6 +804,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg } if ( byoStep === 'codeLocation' || + byoStep === 'dockerfile' || byoStep === 'apiKey' || byoStep === 'subnets' || byoStep === 'securityGroups' || @@ -725,6 +814,9 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ) { return HELP_TEXT.TEXT_INPUT; } + if (byoStep === 'advanced') { + return 'Space toggle · Enter confirm · Esc back'; + } if (byoStep === 'confirm') { return HELP_TEXT.CONFIRM_CANCEL; } @@ -974,6 +1066,21 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg )} + {byoStep === 'dockerfile' && ( + { + setByoConfig(c => ({ ...c, dockerfile: value ? basename(value) : '' })); + goToNextByoStep('dockerfile'); + }} + onCancel={handleByoBack} + /> + )} + {byoStep === 'modelProvider' && ( )} @@ -1031,7 +1140,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg customValidation={validateSecurityGroupIds} onSubmit={value => { setByoConfig(c => ({ ...c, securityGroups: value })); - setByoStep('requestHeaderAllowlist'); + goToNextByoStep('securityGroups'); }} onCancel={handleByoBack} /> @@ -1042,13 +1151,14 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg { const result = validateHeaderAllowlist(value); return result.success ? true : result.error!; }} onSubmit={value => { setByoConfig(c => ({ ...c, requestHeaderAllowlist: value })); - setByoStep('authorizerType'); + goToNextByoStep('requestHeaderAllowlist'); }} onCancel={handleByoBack} /> @@ -1098,6 +1208,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg { if (!value) return true; const n = Number(value); @@ -1117,6 +1228,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg { if (!value) return true; const n = Number(value); @@ -1147,6 +1259,9 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg label: 'Build', value: BUILD_TYPE_OPTIONS.find(o => o.id === byoConfig.buildType)?.title ?? byoConfig.buildType, }, + ...(byoConfig.buildType === 'Container' && byoConfig.dockerfile + ? [{ label: 'Dockerfile', value: byoConfig.dockerfile }] + : []), { label: 'Model Provider', value: `${byoConfig.modelProvider} (${DEFAULT_MODEL_IDS[byoConfig.modelProvider]})`, diff --git a/src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts b/src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts new file mode 100644 index 000000000..2d706e1d0 --- /dev/null +++ b/src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts @@ -0,0 +1,62 @@ +import type { AdvancedSettingId } from '../../generate/types'; +import { computeByoSteps } from '../AddAgentScreen'; +import type { ComputeByoStepsInput } from '../AddAgentScreen'; +import { describe, expect, it } from 'vitest'; + +function makeInput(overrides: Partial = {}): ComputeByoStepsInput { + return { + modelProvider: 'Bedrock', + buildType: 'CodeZip', + networkMode: 'PUBLIC', + authorizerType: 'AWS_IAM', + advancedSettings: new Set(), + ...overrides, + }; +} + +describe('computeByoSteps - dockerfile', () => { + it('Container build with dockerfile selected includes dockerfile step', () => { + const steps = computeByoSteps( + makeInput({ + buildType: 'Container', + advancedSettings: new Set(['dockerfile']), + }) + ); + expect(steps).toContain('dockerfile'); + const advIdx = steps.indexOf('advanced'); + expect(steps[advIdx + 1]).toBe('dockerfile'); + }); + + it('CodeZip build with dockerfile selected does NOT include dockerfile step', () => { + const steps = computeByoSteps( + makeInput({ + buildType: 'CodeZip', + advancedSettings: new Set(['dockerfile']), + }) + ); + expect(steps).not.toContain('dockerfile'); + }); + + it('dockerfile-only selection on Container has steps: advanced, dockerfile, confirm', () => { + const steps = computeByoSteps( + makeInput({ + buildType: 'Container', + advancedSettings: new Set(['dockerfile']), + }) + ); + const advIdx = steps.indexOf('advanced'); + expect(steps.slice(advIdx)).toEqual(['advanced', 'dockerfile', 'confirm']); + }); + + it('dockerfile + lifecycle on Container includes both groups', () => { + const steps = computeByoSteps( + makeInput({ + buildType: 'Container', + advancedSettings: new Set(['dockerfile', 'lifecycle']), + }) + ); + const advIdx = steps.indexOf('advanced'); + expect(steps.slice(advIdx)).toEqual(['advanced', 'dockerfile', 'idleTimeout', 'maxLifetime', 'confirm']); + expect(steps).not.toContain('networkMode'); + }); +}); diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index bcaaa9d3d..d8dc6c899 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -40,6 +40,7 @@ export type AddAgentStep = | 'agentType' | 'codeLocation' | 'buildType' + | 'dockerfile' | 'language' | 'protocol' | 'framework' @@ -69,6 +70,8 @@ export interface AddAgentConfig { entrypoint: string; language: TargetLanguage; buildType: BuildType; + /** Path to custom Dockerfile (copied into code directory at setup) or filename already in code directory. */ + dockerfile?: string; /** Protocol (HTTP, MCP, A2A). Defaults to HTTP. */ protocol: ProtocolMode; framework: SDKFramework; @@ -108,6 +111,7 @@ export const ADD_AGENT_STEP_LABELS: Record = { agentType: 'Type', codeLocation: 'Code', buildType: 'Build', + dockerfile: 'Dockerfile', language: 'Language', protocol: 'Protocol', framework: 'Framework', diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index 574953ae4..63407d2f1 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -15,8 +15,8 @@ import { credentialPrimitive } from '../../../primitives/registry'; import { createRenderer } from '../../../templates'; import type { GenerateConfig } from '../generate/types'; import type { AddAgentConfig } from './types'; -import { mkdirSync } from 'fs'; -import { dirname, join } from 'path'; +import { copyFileSync, existsSync, mkdirSync } from 'fs'; +import { basename, dirname, join, resolve } from 'path'; import { useCallback, useState } from 'react'; // ───────────────────────────────────────────────────────────────────────────── @@ -58,6 +58,7 @@ export function mapByoConfigToAgent(config: AddAgentConfig): AgentEnvSpec { return { name: config.name, build: config.buildType, + ...(config.dockerfile && { dockerfile: config.dockerfile }), entrypoint: config.entrypoint as FilePath, codeLocation: config.codeLocation as DirectoryPath, runtimeVersion: config.pythonVersion, @@ -99,6 +100,7 @@ function mapAddAgentConfigToGenerateConfig(config: AddAgentConfig): GenerateConf return { projectName: config.name, // In create context, this is the agent name buildType: config.buildType, + ...(config.dockerfile && { dockerfile: config.dockerfile }), protocol: config.protocol, sdk: config.framework, modelProvider: config.modelProvider, @@ -212,6 +214,17 @@ async function handleCreatePath( const renderer = createRenderer(renderConfig); await renderer.render({ outputDir: projectRoot }); + // If dockerfile is a path (contains /), copy it into the agent directory (overwriting template default) + if (generateConfig.dockerfile?.includes('/')) { + const sourcePath = resolve(projectRoot, generateConfig.dockerfile); + if (!existsSync(sourcePath)) { + return { ok: false, error: `Dockerfile not found at ${sourcePath}` }; + } + const filename = basename(sourcePath); + copyFileSync(sourcePath, join(agentPath, filename)); + generateConfig.dockerfile = filename; + } + // Write agent to project config if (strategy) { await writeAgentToProject(generateConfig, { configBaseDir, credentialStrategy: strategy }); @@ -304,8 +317,19 @@ async function handleByoPath( const codeDir = join(projectRoot, config.codeLocation.replace(/\/$/, '')); mkdirSync(codeDir, { recursive: true }); + // If dockerfile is a path (contains /), copy it into the code directory and use the filename + let dockerfileName = config.dockerfile; + if (dockerfileName?.includes('/')) { + const sourcePath = resolve(projectRoot, dockerfileName); + if (!existsSync(sourcePath)) { + return { ok: false, error: `Dockerfile not found at ${sourcePath}` }; + } + dockerfileName = basename(sourcePath); + copyFileSync(sourcePath, join(codeDir, dockerfileName)); + } + const project = await configIO.readProjectSpec(); - const agent = mapByoConfigToAgent(config); + const agent = mapByoConfigToAgent({ ...config, dockerfile: dockerfileName }); // Append new agent project.runtimes.push(agent); diff --git a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts index 78b6cf784..c642f7e2e 100644 --- a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts +++ b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts @@ -1,19 +1,17 @@ /** - * Hook for the Config Bundle Hub — fetches deployed bundles - * and enriches them with version counts. + * Hook for the Config Bundle Hub — reads bundles from project config + * and enriches deployed ones with version metadata from the API. */ -import type { - ConfigurationBundleSummary, - ConfigurationBundleVersionSummary, -} from '../../../../cli/aws/agentcore-config-bundles'; -import { - listConfigurationBundleVersions, - listConfigurationBundles, -} from '../../../../cli/aws/agentcore-config-bundles'; +import type { ConfigurationBundleVersionSummary } from '../../../../cli/aws/agentcore-config-bundles'; +import { listConfigurationBundleVersions } from '../../../../cli/aws/agentcore-config-bundles'; import { ConfigIO } from '../../../../lib'; import { useEffect, useRef, useState } from 'react'; -export interface BundleWithMeta extends ConfigurationBundleSummary { +export interface BundleWithMeta { + bundleId: string; + bundleArn: string; + bundleName: string; + description?: string; versionCount: number; branches: string[]; lastUpdated?: string; @@ -41,7 +39,12 @@ export function useConfigBundleHub(): ConfigBundleHubState { setError(undefined); try { const configIO = new ConfigIO(); - const targets = await configIO.resolveAWSDeploymentTargets(); + 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.'); @@ -52,15 +55,41 @@ export function useConfigBundleHub(): ConfigBundleHubState { const resolvedRegion = targets[0]!.region; if (mountedRef.current) setRegion(resolvedRegion); - const result = await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 }); + // 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 ?? {}; - // Enrich each bundle with version metadata + // Build bundle list from project config, enriching with deployed version info const enriched = await Promise.all( - result.bundles.map(async (bundle): Promise => { + 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 try { const versions = await listConfigurationBundleVersions({ region: resolvedRegion, - bundleId: bundle.bundleId, + bundleId: deployed.bundleId, maxResults: 50, }); const branchSet = new Set(); @@ -70,13 +99,23 @@ export function useConfigBundleHub(): ConfigBundleHubState { if (v.versionCreatedAt > latestTs) latestTs = v.versionCreatedAt; } return { - ...bundle, + bundleId: deployed.bundleId, + bundleArn: deployed.bundleArn, + bundleName: bundleSpec.name, + description: bundleSpec.description, versionCount: versions.versions.length, branches: [...branchSet], lastUpdated: latestTs || undefined, }; } catch { - return { ...bundle, versionCount: 0, branches: [] }; + return { + bundleId: deployed.bundleId, + bundleArn: deployed.bundleArn, + bundleName: bundleSpec.name, + description: bundleSpec.description, + versionCount: 0, + branches: [], + }; } }) ); diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx index 4d79d2122..f0155d504 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx @@ -41,7 +41,7 @@ export function AddConfigBundleFlow({ name: config.name, description: config.description || undefined, components: config.components, - branchName: config.branchName || 'mainline', + branchName: config.branchName || 'main', commitMessage: config.commitMessage || `Create ${config.name}`, }).then(result => { if (result.ok) { diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index bcadf2cb0..475b27deb 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -188,7 +188,7 @@ export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames { label: 'Name', value: wizard.config.name }, ...(wizard.config.description ? [{ label: 'Description', value: wizard.config.description }] : []), { label: 'Components', value: componentsPreview }, - { label: 'Branch', value: wizard.config.branchName || 'mainline' }, + { label: 'Branch', value: wizard.config.branchName || 'main' }, { label: 'Message', value: wizard.config.commitMessage || `Create ${wizard.config.name}` }, ]} /> diff --git a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index e6e227bc8..a64325830 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -19,7 +19,7 @@ function getDefaultConfig(): AddConfigBundleConfig { inputMethod: 'inline', components: {}, componentsRaw: '', - branchName: 'mainline', + branchName: 'main', commitMessage: '', }; } diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 8f6b43430..91610b47a 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -260,6 +260,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { const generateConfig: GenerateConfig = { projectName: addAgentConfig.name, buildType: addAgentConfig.buildType, + ...(addAgentConfig.dockerfile && { dockerfile: addAgentConfig.dockerfile }), protocol: addAgentConfig.protocol, sdk: addAgentConfig.framework, modelProvider: addAgentConfig.modelProvider, @@ -272,6 +273,8 @@ export function useCreateFlow(cwd: string): CreateFlowState { requestHeaderAllowlist: addAgentConfig.requestHeaderAllowlist, authorizerType: addAgentConfig.authorizerType, jwtConfig: addAgentConfig.jwtConfig, + idleRuntimeSessionTimeout: addAgentConfig.idleRuntimeSessionTimeout, + maxLifetime: addAgentConfig.maxLifetime, }; logger.logSubStep(`Framework: ${generateConfig.sdk}`); diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index 46a624173..fc08fd4d8 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -19,7 +19,7 @@ interface DevScreenProps { interface ColoredLine { text: string; - color: string; + color?: string; } /** @@ -34,7 +34,9 @@ function formatConversation( const lines: ColoredLine[] = []; for (const msg of conversation) { - if (msg.role === 'user') { + if (msg.role === 'user' && msg.isExec) { + lines.push({ text: msg.content, color: 'magenta' }); + } else if (msg.role === 'user') { lines.push({ text: `> ${msg.content}`, color: 'blue' }); } else if (msg.isError) { for (const errLine of msg.content.split('\n')) { @@ -42,6 +44,8 @@ function formatConversation( } } else if (msg.isHint) { lines.push({ text: msg.content, color: 'cyan' }); + } else if (msg.isExec) { + lines.push({ text: msg.content }); } else { lines.push({ text: msg.content, color: 'green' }); } @@ -131,6 +135,9 @@ export function DevScreen(props: DevScreenProps) { const [selectedAgentName, setSelectedAgentName] = useState(props.agentName); const [agentsLoaded, setAgentsLoaded] = useState(false); const [noAgentsError, setNoAgentsError] = useState(false); + const [isExecInput, setIsExecInput] = useState(false); + const [isContainerExec, setIsContainerExec] = useState(false); + const [execInputEmpty, setExecInputEmpty] = useState(true); const workingDir = props.workingDir ?? process.cwd(); @@ -177,13 +184,15 @@ export function DevScreen(props: DevScreenProps) { configLoaded, actualPort, invoke, + execCommand, + execInContainer, + isContainer, clearConversation, restart, stop, logFilePath, hasMemory, hasVpc, - modelProvider, protocol, mcpTools, fetchMcpTools, @@ -310,6 +319,20 @@ export function DevScreen(props: DevScreenProps) { setMode('input'); // Return to input mode after invoke completes }; + const handleExec = async (command: string) => { + setUserScrolled(false); + await execCommand(command, () => setMode('chat')); + setExecInputEmpty(true); + setMode('input'); + }; + + const handleContainerExec = async (command: string) => { + setUserScrolled(false); + await execInContainer(command, () => setMode('chat')); + setExecInputEmpty(true); + setMode('input'); + }; + useInput( (input, key) => { // Agent selection mode @@ -420,13 +443,18 @@ export function DevScreen(props: DevScreenProps) { // Dynamic help text const backOrQuit = supportedAgents.length > 1 ? 'Esc back' : 'Esc quit'; + const execHint = isContainer ? '! exec local · !! exec container' : '! exec'; const helpText = mode === 'select-agent' ? '↑↓ select · Enter confirm · q quit' : mode === 'input' - ? isMcp - ? 'Enter send · Esc cancel · "list" to refresh tools' - : 'Enter send · Esc cancel' + ? isContainerExec + ? 'Enter run in container · Esc cancel · Backspace to local exec' + : isExecInput + ? `Enter run · Esc cancel · Backspace to exit exec mode${isContainer ? ' · ! container exec' : ''}` + : isMcp + ? `Enter send · Esc cancel · "list" to refresh tools · ${execHint}` + : `Enter send · Esc cancel · ${execHint}` : status === 'starting' ? backOrQuit : isStreaming @@ -468,12 +496,6 @@ export function DevScreen(props: DevScreenProps) { {protocol} )} - {protocol !== 'MCP' && modelProvider && ( - - Provider: - {modelProvider} - - )} Server: {endpointUrl} @@ -546,7 +568,13 @@ export function DevScreen(props: DevScreenProps) { ); return ( - + {/* Conversation display - always visible when there's content */} {(conversation.length > 0 || isStreaming) && ( @@ -577,31 +605,95 @@ export function DevScreen(props: DevScreenProps) { {/* Focused: blue arrow with cursor, type and press Enter to send */} {status === 'running' && mode === 'chat' && !isStreaming && ( - > + {isContainerExec ? '!! ' : isExecInput ? '! ' : '> '} )} {status === 'running' && mode === 'input' && ( - - > - { - if (text.trim()) { - void handleInvoke(text); - } else { - setMode('chat'); + <> + + + {isContainerExec ? '!! ' : isExecInput ? '! ' : '> '} + + { - justCancelledRef.current = true; - setMode('chat'); - }} - onUpArrow={() => scrollUp(1)} - onDownArrow={() => scrollDown(1)} - /> - + onChange={(value, setValue) => { + if (!isExecInput && value.startsWith('!')) { + setIsExecInput(true); + const rest = value.slice(1); + setValue(rest); + setExecInputEmpty(!rest); + } else if ( + isExecInput && + !isContainerExec && + isContainer && + execInputEmpty && + value.startsWith('!') + ) { + setIsContainerExec(true); + const rest = value.slice(1); + setValue(rest); + setExecInputEmpty(!rest); + } else { + setExecInputEmpty(!value); + } + }} + onBackspaceEmpty={ + isContainerExec + ? () => setIsContainerExec(false) + : isExecInput + ? () => setIsExecInput(false) + : undefined + } + onSubmit={text => { + const trimmed = text.trim(); + if (trimmed) { + if (isContainerExec) { + void handleContainerExec(trimmed); + } else if (isExecInput) { + void handleExec(trimmed); + } else { + void handleInvoke(text); + } + } else if (!isExecInput && !isContainerExec) { + setMode('chat'); + } + }} + onCancel={() => { + if (isExecInput) { + setIsContainerExec(false); + setIsExecInput(false); + } else { + justCancelledRef.current = true; + setMode('chat'); + } + }} + onUpArrow={() => scrollUp(1)} + onDownArrow={() => scrollDown(1)} + /> + + {isContainerExec && execInputEmpty && ( + + {' '} + Run a shell command in the container + + )} + {isExecInput && !isContainerExec && execInputEmpty && ( + + {' '} + Run a shell command locally + + )} + )} diff --git a/src/cli/tui/screens/evaluator/AddEvaluatorFlow.tsx b/src/cli/tui/screens/evaluator/AddEvaluatorFlow.tsx index a53aacb2e..ab2a4d2a9 100644 --- a/src/cli/tui/screens/evaluator/AddEvaluatorFlow.tsx +++ b/src/cli/tui/screens/evaluator/AddEvaluatorFlow.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useState } from 'react'; type FlowState = | { name: 'create-wizard' } - | { name: 'create-success'; evaluatorName: string } + | { name: 'create-success'; evaluatorName: string; codePath?: string } | { name: 'error'; message: string }; interface AddEvaluatorFlowProps { @@ -33,7 +33,7 @@ export function AddEvaluatorFlow({ isInteractive = true, onExit, onBack, onDev, (config: AddEvaluatorConfig) => { void createEvaluator(config).then(result => { if (result.ok) { - setFlow({ name: 'create-success', evaluatorName: result.evaluatorName }); + setFlow({ name: 'create-success', evaluatorName: result.evaluatorName, codePath: result.codePath }); return; } setFlow({ name: 'error', message: result.error }); @@ -49,11 +49,15 @@ export function AddEvaluatorFlow({ isInteractive = true, onExit, onBack, onDev, } if (flow.name === 'create-success') { + const detail = flow.codePath + ? `Created evaluator "${flow.evaluatorName}"\n Code: ${flow.codePath}lambda_function.py\n IAM: ${flow.codePath}execution-role-policy.json\n\n Next: Edit lambda_function.py with your evaluation logic, then run \`agentcore deploy\`` + : 'Evaluator added to project in `agentcore/agentcore.json`. Deploy with `agentcore deploy`.'; + return ( ['ratingScale']): string { if ('numerical' in ratingScale && ratingScale.numerical) { return ratingScale.numerical.map(r => `${r.value}=${r.label}`).join(', '); } @@ -43,6 +46,16 @@ function formatRatingScale(ratingScale: EvaluatorConfig['llmAsAJudge']['ratingSc export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames }: AddEvaluatorScreenProps) { const wizard = useAddEvaluatorWizard(); + const evaluatorTypeItems: SelectableItem[] = useMemo( + () => EVALUATOR_TYPE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const codeBasedTypeItems: SelectableItem[] = useMemo( + () => CODE_BASED_TYPE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + const levelItems: SelectableItem[] = useMemo( () => EVALUATION_LEVEL_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), [] @@ -66,6 +79,8 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames [] ); + const isEvaluatorTypeStep = wizard.step === 'evaluator-type'; + const isCodeBasedTypeStep = wizard.step === 'code-based-type'; const isNameStep = wizard.step === 'name'; const isLevelStep = wizard.step === 'level'; const isModelStep = wizard.step === 'model'; @@ -74,8 +89,24 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames const isRatingScaleStep = wizard.step === 'ratingScale'; const isRatingScaleTypeStep = wizard.step === 'ratingScale-type'; const isRatingScaleCustomStep = wizard.step === 'ratingScale-custom'; + const isLambdaArnStep = wizard.step === 'lambda-arn'; + const isTimeoutStep = wizard.step === 'timeout'; const isConfirmStep = wizard.step === 'confirm'; + const evaluatorTypeNav = useListNavigation({ + items: evaluatorTypeItems, + onSelect: item => wizard.selectEvaluatorType(item.id as EvaluatorTypeId), + onExit: onExit, + isActive: isEvaluatorTypeStep, + }); + + const codeBasedTypeNav = useListNavigation({ + items: codeBasedTypeItems, + onSelect: item => wizard.selectCodeBasedType(item.id as CodeBasedTypeId), + onExit: () => wizard.goBack(), + isActive: isCodeBasedTypeStep, + }); + const levelNav = useListNavigation({ items: levelItems, onSelect: item => wizard.setLevel(item.id as EvaluationLevel), @@ -114,25 +145,89 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames isActive: isConfirmStep, }); - const helpText = - isLevelStep || isRatingScaleStep || isModelStep || isRatingScaleTypeStep - ? HELP_TEXT.NAVIGATE_SELECT - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : HELP_TEXT.TEXT_INPUT; + const isSelectStep = + isEvaluatorTypeStep || + isCodeBasedTypeStep || + isLevelStep || + isRatingScaleStep || + isModelStep || + isRatingScaleTypeStep; + + const helpText = isSelectStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; const headerContent = ; + // Build confirm fields based on evaluator type + const confirmFields = useMemo(() => { + if (wizard.evaluatorType === 'llm-as-a-judge') { + const llm = wizard.config.config.llmAsAJudge!; + return [ + { label: 'Type', value: 'LLM-as-a-Judge' }, + { label: 'Name', value: wizard.config.name }, + { label: 'Level', value: wizard.config.level }, + { label: 'Model', value: llm.model }, + { + label: 'Instructions', + value: llm.instructions.length > 60 ? llm.instructions.slice(0, 60) + '...' : llm.instructions, + }, + { label: 'Rating Scale', value: formatRatingScale(llm.ratingScale) }, + ]; + } + + if (wizard.codeBasedType === 'managed') { + const managed = wizard.config.config.codeBased!.managed!; + return [ + { label: 'Type', value: 'Code-based (Managed)' }, + { label: 'Name', value: wizard.config.name }, + { label: 'Level', value: wizard.config.level }, + { label: 'Code', value: managed.codeLocation }, + { label: 'Entrypoint', value: managed.entrypoint }, + { label: 'Timeout', value: `${managed.timeoutSeconds}s` }, + ]; + } + + // external + const external = wizard.config.config.codeBased!.external!; + return [ + { label: 'Type', value: 'Code-based (External)' }, + { label: 'Name', value: wizard.config.name }, + { label: 'Level', value: wizard.config.level }, + { label: 'Lambda ARN', value: external.lambdaArn }, + ]; + }, [wizard.evaluatorType, wizard.codeBasedType, wizard.config]); + return ( + {isEvaluatorTypeStep && ( + + )} + + {isCodeBasedTypeStep && ( + + )} + {isNameStep && ( wizard.goBack()} schema={EvaluatorNameSchema} customValidation={value => !existingEvaluatorNames.includes(value) || 'Evaluator name already exists'} /> @@ -140,7 +235,7 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames {isLevelStep && ( )} - {isConfirmStep && ( - 60 - ? wizard.config.config.llmAsAJudge.instructions.slice(0, 60) + '...' - : wizard.config.config.llmAsAJudge.instructions, - }, - { label: 'Rating Scale', value: formatRatingScale(wizard.config.config.llmAsAJudge.ratingScale) }, - ]} + {isLambdaArnStep && ( + wizard.goBack()} + customValidation={value => + /^arn:aws[a-z-]*:lambda:[a-z0-9-]+:\d{12}:function:.+$/.test(value) || + 'Must be a valid Lambda function ARN' + } + /> + )} + + {isTimeoutStep && ( + wizard.setTimeout(parseInt(value, 10))} + onCancel={() => wizard.goBack()} + customValidation={value => { + const num = parseInt(value, 10); + if (isNaN(num)) return 'Must be a number'; + return (num >= 1 && num <= 300) || 'Must be between 1 and 300'; + }} /> )} + + {isConfirmStep && } ); diff --git a/src/cli/tui/screens/evaluator/types.ts b/src/cli/tui/screens/evaluator/types.ts index 8ac209c4f..4b19bf85d 100644 --- a/src/cli/tui/screens/evaluator/types.ts +++ b/src/cli/tui/screens/evaluator/types.ts @@ -4,7 +4,12 @@ import type { EvaluationLevel, EvaluatorConfig } from '../../../../schema'; // Evaluator Flow Types // ───────────────────────────────────────────────────────────────────────────── +export type EvaluatorTypeId = 'code-based' | 'llm-as-a-judge'; +export type CodeBasedTypeId = 'managed' | 'external'; + export type AddEvaluatorStep = + | 'evaluator-type' + | 'code-based-type' | 'name' | 'level' | 'model' @@ -13,6 +18,8 @@ export type AddEvaluatorStep = | 'ratingScale' | 'ratingScale-type' | 'ratingScale-custom' + | 'lambda-arn' + | 'timeout' | 'confirm'; export interface AddEvaluatorConfig { @@ -22,6 +29,8 @@ export interface AddEvaluatorConfig { } export const EVALUATOR_STEP_LABELS: Record = { + 'evaluator-type': 'Type', + 'code-based-type': 'Mode', name: 'Name', level: 'Level', model: 'Model', @@ -30,9 +39,40 @@ export const EVALUATOR_STEP_LABELS: Record = { ratingScale: 'Scale', 'ratingScale-type': 'Scale', 'ratingScale-custom': 'Scale', + 'lambda-arn': 'Lambda', + timeout: 'Timeout', confirm: 'Confirm', }; +// ───────────────────────────────────────────────────────────────────────────── +// Evaluator Type Options +// ───────────────────────────────────────────────────────────────────────────── + +export const EVALUATOR_TYPE_OPTIONS = [ + { + id: 'code-based', + title: 'Code-based (custom Lambda function)', + description: 'Write custom Python code for evaluation logic', + }, + { + id: 'llm-as-a-judge', + title: 'LLM-as-a-Judge (model-based)', + description: 'Use a foundation model to evaluate quality', + }, +] as const; + +export const CODE_BASED_TYPE_OPTIONS = [ + { + id: 'managed', + title: 'Create new (CLI scaffolds and deploys the Lambda for you)', + description: 'CLI scaffolds code and deploys Lambda', + }, + { id: 'external', title: 'Existing Lambda ARN (bring your own)', description: 'Use an existing Lambda function' }, +] as const; + +export const DEFAULT_CODE_TIMEOUT = 60; +export const DEFAULT_CODE_ENTRYPOINT = 'lambda_function.handler'; + // ───────────────────────────────────────────────────────────────────────────── // UI Option Constants // ───────────────────────────────────────────────────────────────────────────── @@ -150,7 +190,7 @@ export interface RatingScalePreset { id: string; title: string; description: string; - ratingScale: EvaluatorConfig['llmAsAJudge']['ratingScale']; + ratingScale: NonNullable['ratingScale']; } export const CUSTOM_RATING_SCALE_ID = '__custom__'; @@ -170,7 +210,9 @@ export const RATING_SCALE_TYPE_OPTIONS = [ export function parseCustomRatingScale( input: string, type: CustomRatingScaleType -): { success: true; ratingScale: EvaluatorConfig['llmAsAJudge']['ratingScale'] } | { success: false; error: string } { +): + | { success: true; ratingScale: NonNullable['ratingScale'] } + | { success: false; error: string } { const entries = input .split(',') .map(e => e.trim()) diff --git a/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts b/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts index f0bcc33da..1bc54f50b 100644 --- a/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts +++ b/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts @@ -1,38 +1,100 @@ import type { EvaluationLevel, EvaluatorConfig } from '../../../../schema'; -import type { AddEvaluatorConfig, AddEvaluatorStep, CustomRatingScaleType } from './types'; -import { CUSTOM_MODEL_ID, CUSTOM_RATING_SCALE_ID, DEFAULT_MODEL } from './types'; -import { useCallback, useState } from 'react'; +import type { + AddEvaluatorConfig, + AddEvaluatorStep, + CodeBasedTypeId, + CustomRatingScaleType, + EvaluatorTypeId, +} from './types'; +import { + CUSTOM_MODEL_ID, + CUSTOM_RATING_SCALE_ID, + DEFAULT_CODE_ENTRYPOINT, + DEFAULT_CODE_TIMEOUT, + DEFAULT_MODEL, +} from './types'; +import { useCallback, useMemo, useState } from 'react'; -const ALL_STEPS: AddEvaluatorStep[] = ['name', 'level', 'model', 'instructions', 'ratingScale', 'confirm']; +const LLM_STEPS: AddEvaluatorStep[] = [ + 'evaluator-type', + 'name', + 'level', + 'model', + 'instructions', + 'ratingScale', + 'confirm', +]; +const CODE_MANAGED_STEPS: AddEvaluatorStep[] = [ + 'evaluator-type', + 'code-based-type', + 'name', + 'level', + 'timeout', + 'confirm', +]; +const CODE_EXTERNAL_STEPS: AddEvaluatorStep[] = [ + 'evaluator-type', + 'code-based-type', + 'name', + 'level', + 'lambda-arn', + 'confirm', +]; -function getDefaultConfig(): AddEvaluatorConfig { +function getSteps(evalType: EvaluatorTypeId, codeType: CodeBasedTypeId): AddEvaluatorStep[] { + if (evalType === 'llm-as-a-judge') return LLM_STEPS; + if (codeType === 'external') return CODE_EXTERNAL_STEPS; + return CODE_MANAGED_STEPS; +} + +function getDefaultLlmConfig(): EvaluatorConfig { return { - name: '', - level: 'SESSION', - config: { - llmAsAJudge: { - model: DEFAULT_MODEL, - instructions: '', - ratingScale: { - numerical: [ - { value: 1, label: 'Poor', definition: 'Fails to meet expectations' }, - { value: 5, label: 'Excellent', definition: 'Far exceeds expectations' }, - ], - }, + llmAsAJudge: { + model: DEFAULT_MODEL, + instructions: '', + ratingScale: { + numerical: [ + { value: 1, label: 'Poor', definition: 'Fails to meet expectations' }, + { value: 5, label: 'Excellent', definition: 'Far exceeds expectations' }, + ], }, }, }; } export function useAddEvaluatorWizard() { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('name'); + const [evaluatorType, setEvaluatorType] = useState('code-based'); + const [codeBasedType, setCodeBasedType] = useState('managed'); + const [name, setNameState] = useState(''); + const [level, setLevelState] = useState('SESSION'); + const [llmConfig, setLlmConfig] = useState>({ + model: DEFAULT_MODEL, + instructions: '', + ratingScale: { + numerical: [ + { value: 1, label: 'Poor', definition: 'Fails to meet expectations' }, + { value: 5, label: 'Excellent', definition: 'Far exceeds expectations' }, + ], + }, + }); + const [lambdaArn, setLambdaArnState] = useState(''); + const [timeout, setTimeoutState] = useState(DEFAULT_CODE_TIMEOUT); const [customRatingScaleType, setCustomRatingScaleType] = useState('numerical'); + const [step, setStep] = useState('evaluator-type'); - const currentIndex = ALL_STEPS.indexOf(step); + const steps = useMemo(() => getSteps(evaluatorType, codeBasedType), [evaluatorType, codeBasedType]); + const currentIndex = steps.indexOf(step); + + const nextStep = useCallback( + (currentStep: AddEvaluatorStep): AddEvaluatorStep | undefined => { + const idx = steps.indexOf(currentStep); + return steps[idx + 1]; + }, + [steps] + ); const goBack = useCallback(() => { - // Sub-steps not in ALL_STEPS — go back to their parent select + // Sub-steps not in main steps array — go back to their parent select if (step === 'model-custom') { setStep('model'); return; @@ -41,18 +103,66 @@ export function useAddEvaluatorWizard() { setStep(step === 'ratingScale-custom' ? 'ratingScale-type' : 'ratingScale'); return; } - const prevStep = ALL_STEPS[currentIndex - 1]; + const prevStep = steps[currentIndex - 1]; if (prevStep) setStep(prevStep); - }, [currentIndex, step]); + }, [currentIndex, step, steps]); + + // Build the final config based on current state + const config: AddEvaluatorConfig = useMemo(() => { + if (evaluatorType === 'llm-as-a-judge') { + return { + name, + level, + config: { llmAsAJudge: llmConfig }, + }; + } + + if (codeBasedType === 'external') { + return { + name, + level, + config: { + codeBased: { + external: { lambdaArn }, + }, + }, + }; + } + + // managed + return { + name, + level, + config: { + codeBased: { + managed: { + codeLocation: `app/${name}/`, + entrypoint: DEFAULT_CODE_ENTRYPOINT, + timeoutSeconds: timeout, + additionalPolicies: ['execution-role-policy.json'], + }, + }, + }, + }; + }, [evaluatorType, codeBasedType, name, level, llmConfig, lambdaArn, timeout]); + + const selectEvaluatorType = useCallback((type: EvaluatorTypeId) => { + setEvaluatorType(type); + if (type === 'code-based') { + setStep('code-based-type'); + } else { + setStep('name'); + } + }, []); - const nextStep = useCallback((currentStep: AddEvaluatorStep): AddEvaluatorStep | undefined => { - const idx = ALL_STEPS.indexOf(currentStep); - return ALL_STEPS[idx + 1]; + const selectCodeBasedType = useCallback((type: CodeBasedTypeId) => { + setCodeBasedType(type); + setStep('name'); }, []); const setName = useCallback( - (name: string) => { - setConfig(c => ({ ...c, name })); + (value: string) => { + setNameState(value); const next = nextStep('name'); if (next) setStep(next); }, @@ -60,8 +170,8 @@ export function useAddEvaluatorWizard() { ); const setLevel = useCallback( - (level: EvaluationLevel) => { - setConfig(c => ({ ...c, level })); + (value: EvaluationLevel) => { + setLevelState(value); const next = nextStep('level'); if (next) setStep(next); }, @@ -74,12 +184,7 @@ export function useAddEvaluatorWizard() { setStep('model-custom'); return; } - setConfig(c => ({ - ...c, - config: { - llmAsAJudge: { ...c.config.llmAsAJudge, model: modelId }, - }, - })); + setLlmConfig(c => ({ ...c, model: modelId })); const next = nextStep('model'); if (next) setStep(next); }, @@ -88,13 +193,7 @@ export function useAddEvaluatorWizard() { const setCustomModel = useCallback( (model: string) => { - setConfig(c => ({ - ...c, - config: { - llmAsAJudge: { ...c.config.llmAsAJudge, model }, - }, - })); - // After custom model input, go to instructions (same as after model select) + setLlmConfig(c => ({ ...c, model })); const next = nextStep('model'); if (next) setStep(next); }, @@ -103,12 +202,7 @@ export function useAddEvaluatorWizard() { const setInstructions = useCallback( (instructions: string) => { - setConfig(c => ({ - ...c, - config: { - llmAsAJudge: { ...c.config.llmAsAJudge, instructions }, - }, - })); + setLlmConfig(c => ({ ...c, instructions })); const next = nextStep('instructions'); if (next) setStep(next); }, @@ -116,18 +210,13 @@ export function useAddEvaluatorWizard() { ); const selectRatingScale = useCallback( - (presetIdOrCustom: string, ratingScale?: EvaluatorConfig['llmAsAJudge']['ratingScale']) => { + (presetIdOrCustom: string, ratingScale?: NonNullable['ratingScale']) => { if (presetIdOrCustom === CUSTOM_RATING_SCALE_ID) { setStep('ratingScale-type'); return; } if (ratingScale) { - setConfig(c => ({ - ...c, - config: { - llmAsAJudge: { ...c.config.llmAsAJudge, ratingScale }, - }, - })); + setLlmConfig(c => ({ ...c, ratingScale })); } const next = nextStep('ratingScale'); if (next) setStep(next); @@ -141,31 +230,54 @@ export function useAddEvaluatorWizard() { }, []); const setCustomRatingScale = useCallback( - (ratingScale: EvaluatorConfig['llmAsAJudge']['ratingScale']) => { - setConfig(c => ({ - ...c, - config: { - llmAsAJudge: { ...c.config.llmAsAJudge, ratingScale }, - }, - })); + (ratingScale: NonNullable['ratingScale']) => { + setLlmConfig(c => ({ ...c, ratingScale })); const next = nextStep('ratingScale'); if (next) setStep(next); }, [nextStep] ); + const setLambdaArn = useCallback( + (arn: string) => { + setLambdaArnState(arn); + const next = nextStep('lambda-arn'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setTimeout = useCallback( + (value: number) => { + setTimeoutState(value); + const next = nextStep('timeout'); + if (next) setStep(next); + }, + [nextStep] + ); + const reset = useCallback(() => { - setConfig(getDefaultConfig()); - setStep('name'); + setEvaluatorType('code-based'); + setCodeBasedType('managed'); + setNameState(''); + setLevelState('SESSION'); + setLlmConfig(getDefaultLlmConfig().llmAsAJudge!); + setLambdaArnState(''); + setTimeoutState(DEFAULT_CODE_TIMEOUT); + setStep('evaluator-type'); }, []); return { config, step, - steps: ALL_STEPS, + steps, currentIndex, + evaluatorType, + codeBasedType, customRatingScaleType, goBack, + selectEvaluatorType, + selectCodeBasedType, setName, setLevel, selectModel, @@ -174,6 +286,8 @@ export function useAddEvaluatorWizard() { selectRatingScale, selectCustomRatingScaleType, setCustomRatingScale, + setLambdaArn, + setTimeout, reset, }; } diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index c1f2c0866..b4f25f660 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -3,14 +3,22 @@ import { DEFAULT_MODEL_IDS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN, Projec import { parseAndNormalizeHeaders, validateHeaderAllowlist } from '../../../commands/shared/header-utils'; import { validateSecurityGroupIds, validateSubnetIds } from '../../../commands/shared/vpc-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; -import { ApiKeySecretInput, Panel, SelectList, StepIndicator, TextInput } from '../../components'; +import { + ApiKeySecretInput, + Panel, + PathInput, + SelectList, + StepIndicator, + TextInput, + WizardMultiSelect, +} from '../../components'; import type { SelectableItem } from '../../components'; import { JwtConfigInput, useJwtConfigFlow } from '../../components/jwt-config'; -import { useListNavigation } from '../../hooks'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { RUNTIME_AUTHORIZER_TYPE_OPTIONS } from '../agent/types'; -import type { BuildType, GenerateConfig, GenerateStep, MemoryOption, ProtocolMode } from './types'; +import type { AdvancedSettingId, BuildType, GenerateConfig, GenerateStep, MemoryOption, ProtocolMode } from './types'; import { - ADVANCED_OPTIONS, + ADVANCED_SETTING_OPTIONS, BUILD_TYPE_OPTIONS, LANGUAGE_OPTIONS, MEMORY_OPTIONS, @@ -22,6 +30,7 @@ import { } from './types'; import type { useGenerateWizard } from './useGenerateWizard'; import { Box, Text, useInput } from 'ink'; +import { basename } from 'path'; // Helper to get provider display name and env var name from ModelProvider function getProviderInfo(provider: ModelProvider): { name: string; envVarName: string } { @@ -83,8 +92,6 @@ export function GenerateWizardUI({ })); case 'memory': return MEMORY_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); - case 'advanced': - return ADVANCED_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); case 'networkMode': return NETWORK_MODE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); case 'authorizerType': @@ -96,7 +103,9 @@ export function GenerateWizardUI({ const items = getItems(); const isSelectStep = items.length > 0; + const isAdvancedStep = wizard.step === 'advanced'; const isTextStep = wizard.step === 'projectName'; + const isDockerfileStep = wizard.step === 'dockerfile'; const isApiKeyStep = wizard.step === 'apiKey'; const isSubnetsStep = wizard.step === 'subnets'; const isSecurityGroupsStep = wizard.step === 'securityGroups'; @@ -106,6 +115,11 @@ export function GenerateWizardUI({ const isMaxLifetimeStep = wizard.step === 'maxLifetime'; const isConfirmStep = wizard.step === 'confirm'; + // Advanced multi-select items — filter out dockerfile when not a Container build + const advancedItems: SelectableItem[] = ADVANCED_SETTING_OPTIONS.filter( + o => o.id !== 'dockerfile' || wizard.config.buildType === 'Container' + ).map(o => ({ id: o.id, title: o.title, description: o.description })); + const handleSelect = (item: SelectableItem) => { switch (wizard.step) { case 'language': @@ -126,9 +140,6 @@ export function GenerateWizardUI({ case 'memory': wizard.setMemory(item.id as MemoryOption); break; - case 'advanced': - wizard.setAdvanced(item.id === 'yes'); - break; case 'networkMode': wizard.setNetworkMode(item.id as NetworkMode); break; @@ -142,11 +153,20 @@ export function GenerateWizardUI({ items, onSelect: handleSelect, onExit: onBack, - isActive: isActive && isSelectStep, + isActive: isActive && isSelectStep && !isAdvancedStep, isDisabled: item => item.disabled ?? false, resetKey: wizard.step, }); + const advancedNav = useMultiSelectNavigation({ + items: advancedItems, + getId: item => item.id, + onConfirm: selectedIds => wizard.setAdvanced(selectedIds as AdvancedSettingId[]), + onExit: onBack, + isActive: isActive && isAdvancedStep, + requireSelection: false, + }); + // JWT config flow for CUSTOM_JWT authorizer const jwtFlow = useJwtConfigFlow({ onComplete: jwtConfig => { @@ -188,7 +208,30 @@ export function GenerateWizardUI({ )} - {isSelectStep && } + {isSelectStep && !isAdvancedStep && } + + {isAdvancedStep && ( + + )} + + {isDockerfileStep && ( + { + wizard.setDockerfile(value ? basename(value) : undefined); + }} + onCancel={onBack} + /> + )} {isApiKeyStep && ( { const result = validateHeaderAllowlist(value); return result.success ? true : result.error!; @@ -291,6 +335,7 @@ export function GenerateWizardUI({ { if (!value) return true; const n = Number(value); @@ -313,6 +358,7 @@ export function GenerateWizardUI({ { if (!value) return true; const n = Number(value); @@ -347,6 +393,7 @@ export function getWizardHelpText(step: GenerateStep): string { if (step === 'confirm') return 'Enter/Y confirm · Esc back'; if ( step === 'projectName' || + step === 'dockerfile' || step === 'subnets' || step === 'securityGroups' || step === 'requestHeaderAllowlist' || @@ -356,6 +403,7 @@ export function getWizardHelpText(step: GenerateStep): string { return 'Enter submit · Esc cancel'; if (step === 'apiKey') return 'Enter submit · Tab show/hide · Esc back'; if (step === 'jwtConfig') return 'Enter submit · Esc back'; + if (step === 'advanced') return 'Space toggle · Enter confirm · Esc back'; return '↑↓ navigate · Enter select · Esc back'; } @@ -405,6 +453,12 @@ function ConfirmView({ config, credentialProjectName }: { config: GenerateConfig Build: {buildTypeLabel} + {config.buildType === 'Container' && config.dockerfile && ( + + Dockerfile: + {config.dockerfile} + + )} Protocol: {protocolLabel} diff --git a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx index cbaeb008a..d303be1d6 100644 --- a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx +++ b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx @@ -1,8 +1,9 @@ +import { validateDockerfileInput } from '../types'; import { useGenerateWizard } from '../useGenerateWizard'; import { Text } from 'ink'; import { render } from 'ink-testing-library'; import React, { act, useImperativeHandle } from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- // Imperative harness — exposes wizard methods via ref for act()-based tests @@ -20,7 +21,7 @@ const Harness = React.forwardRef((props return ( step:{wizard.step} steps:{wizard.steps.join(',')} networkMode:{wizard.config.networkMode ?? 'undefined'}{' '} - advancedSelected:{String(wizard.advancedSelected)} + advancedSelected:{String(wizard.advancedSelected)} dockerfile:{wizard.config.dockerfile ?? 'undefined'} ); }); @@ -89,12 +90,12 @@ describe('useGenerateWizard — advanced config gate', () => { }); } - it('setAdvanced(false) jumps to confirm with PUBLIC defaults', () => { + it('setAdvanced([]) jumps to confirm with PUBLIC defaults', () => { const { ref, lastFrame } = setup(); walkToAdvanced(ref); expect(lastFrame()).toContain('step:advanced'); - act(() => ref.current!.wizard.setAdvanced(false)); + act(() => ref.current!.wizard.setAdvanced([])); const frame = lastFrame()!; expect(frame).toContain('step:confirm'); @@ -102,22 +103,28 @@ describe('useGenerateWizard — advanced config gate', () => { expect(frame).toContain('advancedSelected:false'); }); - it('setAdvanced(true) navigates to networkMode', () => { + it('setAdvanced with network selected navigates to networkMode', () => { + vi.useFakeTimers(); const { ref, lastFrame } = setup(); walkToAdvanced(ref); - act(() => ref.current!.wizard.setAdvanced(true)); + act(() => ref.current!.wizard.setAdvanced(['network', 'headers', 'auth', 'lifecycle'])); + // Flush setTimeout used for navigating to first sub-step + act(() => { + vi.runAllTimers(); + }); const frame = lastFrame()!; expect(frame).toContain('step:networkMode'); expect(frame).toContain('advancedSelected:true'); + vi.useRealTimers(); }); - it('setAdvanced(true) injects networkMode and requestHeaderAllowlist into steps', () => { + it('setAdvanced with settings injects sub-steps after advanced', () => { const { ref } = setup(); walkToAdvanced(ref); - act(() => ref.current!.wizard.setAdvanced(true)); + act(() => ref.current!.wizard.setAdvanced(['network', 'headers', 'auth', 'lifecycle'])); const steps = ref.current!.wizard.steps; const advIdx = steps.indexOf('advanced'); @@ -132,11 +139,11 @@ describe('useGenerateWizard — advanced config gate', () => { ]); }); - it('setAdvanced(true) then VPC injects subnets and securityGroups', () => { + it('network setting with VPC injects subnets and securityGroups', () => { const { ref } = setup(); walkToAdvanced(ref); - act(() => ref.current!.wizard.setAdvanced(true)); + act(() => ref.current!.wizard.setAdvanced(['network', 'headers', 'auth', 'lifecycle'])); act(() => ref.current!.wizard.setNetworkMode('VPC')); const steps = ref.current!.wizard.steps; @@ -156,7 +163,7 @@ describe('useGenerateWizard — advanced config gate', () => { }); describe('state cleanup on toggle', () => { - function walkToAdvancedAndSelectYes(ref: React.RefObject) { + function walkToAdvancedAndSelectSettings(ref: React.RefObject) { act(() => { ref.current!.wizard.setProjectName('Test'); ref.current!.wizard.setLanguage('Python'); @@ -166,18 +173,18 @@ describe('useGenerateWizard — advanced config gate', () => { ref.current!.wizard.setModelProvider('Bedrock'); ref.current!.wizard.setMemory('none'); }); - act(() => ref.current!.wizard.setAdvanced(true)); + act(() => ref.current!.wizard.setAdvanced(['network', 'headers', 'auth', 'lifecycle'])); act(() => ref.current!.wizard.setNetworkMode('VPC')); act(() => ref.current!.wizard.setSubnets(['subnet-123'])); act(() => ref.current!.wizard.setSecurityGroups(['sg-456'])); } - it('switching from Yes to No clears VPC config', () => { + it('switching to empty selection clears VPC config', () => { const { ref } = setup(); - walkToAdvancedAndSelectYes(ref); + walkToAdvancedAndSelectSettings(ref); - // Now go back and select No - act(() => ref.current!.wizard.setAdvanced(false)); + // Now go back and deselect all + act(() => ref.current!.wizard.setAdvanced([])); const w = ref.current!.wizard; expect(w.step).toBe('confirm'); @@ -191,9 +198,9 @@ describe('useGenerateWizard — advanced config gate', () => { it('config subnets and securityGroups are cleared to undefined', () => { const { ref } = setup(); - walkToAdvancedAndSelectYes(ref); + walkToAdvancedAndSelectSettings(ref); - act(() => ref.current!.wizard.setAdvanced(false)); + act(() => ref.current!.wizard.setAdvanced([])); expect(ref.current!.wizard.config.subnets).toBeUndefined(); expect(ref.current!.wizard.config.securityGroups).toBeUndefined(); @@ -272,6 +279,112 @@ describe('useGenerateWizard — advanced config gate', () => { }); }); + describe('dockerfile advanced setting', () => { + function walkToAdvancedWithContainer(ref: React.RefObject) { + act(() => { + ref.current!.wizard.setProjectName('Test'); + ref.current!.wizard.setLanguage('Python'); + ref.current!.wizard.setBuildType('Container'); + ref.current!.wizard.setProtocol('HTTP'); + ref.current!.wizard.setSdk('Strands'); + ref.current!.wizard.setModelProvider('Bedrock'); + ref.current!.wizard.setMemory('none'); + }); + } + + it('setAdvanced with only dockerfile navigates to dockerfile step', () => { + vi.useFakeTimers(); + const { ref, lastFrame } = setup(); + walkToAdvancedWithContainer(ref); + expect(lastFrame()).toContain('step:advanced'); + + act(() => ref.current!.wizard.setAdvanced(['dockerfile'])); + act(() => { + vi.runAllTimers(); + }); + + expect(lastFrame()).toContain('step:dockerfile'); + vi.useRealTimers(); + }); + + it('setDockerfile navigates to confirm when only dockerfile is selected', () => { + vi.useFakeTimers(); + const { ref, lastFrame } = setup(); + walkToAdvancedWithContainer(ref); + + act(() => ref.current!.wizard.setAdvanced(['dockerfile'])); + act(() => { + vi.runAllTimers(); + }); + expect(lastFrame()).toContain('step:dockerfile'); + + act(() => ref.current!.wizard.setDockerfile('Dockerfile.gpu')); + act(() => { + vi.runAllTimers(); + }); + + expect(lastFrame()).toContain('step:confirm'); + expect(lastFrame()).toContain('dockerfile:Dockerfile.gpu'); + vi.useRealTimers(); + }); + + it('dockerfile + lifecycle injects both sub-steps but not networkMode', () => { + const { ref } = setup(); + walkToAdvancedWithContainer(ref); + + act(() => ref.current!.wizard.setAdvanced(['dockerfile', 'lifecycle'])); + + const steps = ref.current!.wizard.steps; + const advIdx = steps.indexOf('advanced'); + expect(steps.slice(advIdx)).toEqual(['advanced', 'dockerfile', 'idleTimeout', 'maxLifetime', 'confirm']); + expect(steps).not.toContain('networkMode'); + }); + + it('dockerfile is hidden for CodeZip builds even when selected', () => { + const { ref } = setup(); + // Use CodeZip (default from walkToAdvanced) + act(() => { + ref.current!.wizard.setProjectName('Test'); + ref.current!.wizard.setLanguage('Python'); + ref.current!.wizard.setBuildType('CodeZip'); + ref.current!.wizard.setProtocol('HTTP'); + ref.current!.wizard.setSdk('Strands'); + ref.current!.wizard.setModelProvider('Bedrock'); + ref.current!.wizard.setMemory('none'); + }); + + // Even if dockerfile somehow gets into the set, it shouldn't appear for CodeZip + act(() => ref.current!.wizard.setAdvanced(['dockerfile', 'lifecycle'])); + + const steps = ref.current!.wizard.steps; + expect(steps).not.toContain('dockerfile'); + expect(steps).toContain('idleTimeout'); + }); + + it('deselecting all advanced clears dockerfile config', () => { + vi.useFakeTimers(); + const { ref } = setup(); + walkToAdvancedWithContainer(ref); + + act(() => ref.current!.wizard.setAdvanced(['dockerfile'])); + act(() => { + vi.runAllTimers(); + }); + act(() => ref.current!.wizard.setDockerfile('Dockerfile.gpu')); + act(() => { + vi.runAllTimers(); + }); + expect(ref.current!.wizard.config.dockerfile).toBe('Dockerfile.gpu'); + + // Go back and deselect all + act(() => ref.current!.wizard.setAdvanced([])); + + expect(ref.current!.wizard.config.dockerfile).toBeUndefined(); + expect(ref.current!.wizard.step).toBe('confirm'); + vi.useRealTimers(); + }); + }); + describe('reset clears advancedSelected', () => { it('reset returns advancedSelected to false', () => { const { ref, lastFrame } = setup(); @@ -283,7 +396,7 @@ describe('useGenerateWizard — advanced config gate', () => { ref.current!.wizard.setSdk('Strands'); ref.current!.wizard.setModelProvider('Bedrock'); ref.current!.wizard.setMemory('none'); - ref.current!.wizard.setAdvanced(true); + ref.current!.wizard.setAdvanced(['network']); }); expect(lastFrame()).toContain('advancedSelected:true'); @@ -293,3 +406,32 @@ describe('useGenerateWizard — advanced config gate', () => { }); }); }); + +describe('validateDockerfileInput', () => { + it('accepts empty string (use default)', () => { + expect(validateDockerfileInput('')).toBe(true); + expect(validateDockerfileInput(' ')).toBe(true); + }); + + it.each(['Dockerfile', 'Dockerfile.gpu', 'Dockerfile.dev-v2', 'my.Dockerfile'])( + 'accepts valid filename "%s"', + name => { + expect(validateDockerfileInput(name)).toBe(true); + } + ); + + it('accepts path input (delegates existence check to caller)', () => { + expect(validateDockerfileInput('../shared/Dockerfile.gpu')).toBe(true); + expect(validateDockerfileInput('/absolute/path/Dockerfile')).toBe(true); + }); + + it('rejects name exceeding 255 characters', () => { + const longName = 'D' + 'a'.repeat(255); + expect(validateDockerfileInput(longName)).toContain('255 characters'); + }); + + it.each(['.hidden', '-bad', '_under'])('rejects invalid filename "%s"', name => { + const result = validateDockerfileInput(name); + expect(result).not.toBe(true); + }); +}); diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 1b5d3e7ed..eb1916399 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -14,6 +14,7 @@ export type GenerateStep = | 'projectName' | 'language' | 'buildType' + | 'dockerfile' | 'protocol' | 'sdk' | 'modelProvider' @@ -38,6 +39,8 @@ export type { BuildType, ModelProvider, ProtocolMode, SDKFramework, TargetLangua export interface GenerateConfig { projectName: string; buildType: BuildType; + /** Path to custom Dockerfile (copied into code directory at setup) or filename already in code directory. */ + dockerfile?: string; protocol: ProtocolMode; sdk: SDKFramework; modelProvider: ModelProvider; @@ -77,6 +80,7 @@ export const STEP_LABELS: Record = { projectName: 'Name', language: 'Target Language', buildType: 'Build', + dockerfile: 'Dockerfile', protocol: 'Protocol', sdk: 'Framework', modelProvider: 'Model', @@ -149,11 +153,40 @@ export const NETWORK_MODE_OPTIONS = [ { id: 'VPC', title: 'VPC', description: 'Attach to your VPC' }, ] as const; -export const ADVANCED_OPTIONS = [ - { id: 'no', title: 'No, use defaults', description: 'Public network, no VPC' }, - { id: 'yes', title: 'Yes, customize', description: undefined }, +export type AdvancedSettingId = 'dockerfile' | 'network' | 'headers' | 'auth' | 'lifecycle'; + +export const ADVANCED_SETTING_OPTIONS = [ + { id: 'dockerfile', title: 'Custom Dockerfile', description: 'Specify a custom Dockerfile path' }, + { id: 'network', title: 'VPC networking', description: 'Attach to your VPC with subnets & security groups' }, + { id: 'headers', title: 'Request header allowlist', description: 'Allow custom headers through to your agent' }, + { 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' }, ] as const; +/** Dockerfile filename regex — must match the Zod schema in agent-env.ts */ +const DOCKERFILE_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +/** + * Validate a Dockerfile input value from the TUI. + * Returns `true` if valid, or an error message string if invalid. + * Does NOT check file existence for path inputs — callers handle that. + */ +export function validateDockerfileInput(value: string): true | string { + const trimmed = value.trim(); + if (!trimmed) return true; // empty is valid (means "use default") + if (trimmed.includes('/')) { + // Path input — caller must check existsSync separately + return true; + } + if (trimmed.length > 255) { + return 'Dockerfile name must be 255 characters or fewer'; + } + if (!DOCKERFILE_NAME_REGEX.test(trimmed)) { + return 'Must be a valid filename (starts with alphanumeric)'; + } + return true; +} + export const MEMORY_OPTIONS = [ { id: 'none', title: 'None', description: 'No memory' }, { id: 'shortTerm', title: 'Short-term memory', description: 'Context within a session' }, diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 4808ab22c..982457aa2 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -1,7 +1,7 @@ import type { NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; import { ProjectNameSchema } from '../../../../schema'; import type { JwtConfigOptions } from '../../../primitives/auth-utils'; -import type { BuildType, GenerateConfig, GenerateStep, MemoryOption, ProtocolMode } from './types'; +import type { AdvancedSettingId, BuildType, GenerateConfig, GenerateStep, MemoryOption, ProtocolMode } from './types'; import { BASE_GENERATE_STEPS, getModelProviderOptionsForSdk } from './types'; import { useCallback, useMemo, useState } from 'react'; @@ -35,11 +35,13 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { // Track if user has selected a framework (moved past sdk step) const [sdkSelected, setSdkSelected] = useState(false); - const [advancedSelected, setAdvancedSelected] = useState(false); + const [advancedSettings, setAdvancedSettings] = useState>(new Set()); + + const advancedSelected = advancedSettings.size > 0; // Steps depend on protocol, SDK, model provider, network mode, and whether we have an initial name // MCP skips sdk, modelProvider, apiKey, memory - // Filter out: projectName if initialName, apiKey for Bedrock, subnets/securityGroups for non-VPC + // Advanced sub-steps only appear for settings the user selected in the multi-select const steps = useMemo(() => { let filtered = BASE_GENERATE_STEPS; if (hasInitialName) { @@ -59,25 +61,40 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { if (advancedSelected) { const advancedIndex = filtered.indexOf('advanced'); const afterAdvanced = advancedIndex + 1; - const networkSteps: GenerateStep[] = - config.networkMode === 'VPC' ? ['networkMode', 'subnets', 'securityGroups'] : ['networkMode']; - filtered = [ - ...filtered.slice(0, afterAdvanced), - ...networkSteps, - 'requestHeaderAllowlist', - 'authorizerType', - 'idleTimeout', - 'maxLifetime', - ...filtered.slice(afterAdvanced), - ]; + const subSteps: GenerateStep[] = []; + // Dockerfile — only for Container builds when user selected it + if (advancedSettings.has('dockerfile') && config.buildType === 'Container') { + subSteps.push('dockerfile'); + } + // Network — always networkMode, plus subnets/securityGroups for VPC + if (advancedSettings.has('network')) { + subSteps.push('networkMode'); + if (config.networkMode === 'VPC') { + subSteps.push('subnets', 'securityGroups'); + } + } + // Headers + if (advancedSettings.has('headers')) { + subSteps.push('requestHeaderAllowlist'); + } + // Auth + if (advancedSettings.has('auth')) { + subSteps.push('authorizerType'); + } + // Lifecycle + if (advancedSettings.has('lifecycle')) { + subSteps.push('idleTimeout', 'maxLifetime'); + } + filtered = [...filtered.slice(0, afterAdvanced), ...subSteps, ...filtered.slice(afterAdvanced)]; } // Add jwtConfig step after authorizerType when CUSTOM_JWT is selected - if (config.authorizerType === 'CUSTOM_JWT') { + if (config.authorizerType === 'CUSTOM_JWT' && filtered.includes('authorizerType')) { const authIndex = filtered.indexOf('authorizerType'); filtered = [...filtered.slice(0, authIndex + 1), 'jwtConfig', ...filtered.slice(authIndex + 1)]; } return filtered; }, [ + config.buildType, config.modelProvider, config.sdk, config.protocol, @@ -86,6 +103,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { hasInitialName, sdkSelected, advancedSelected, + advancedSettings, ]); const currentIndex = steps.indexOf(step); @@ -108,7 +126,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { }, []); const setBuildType = useCallback((buildType: BuildType) => { - setConfig(c => ({ ...c, buildType })); + setConfig(c => ({ ...c, buildType, dockerfile: undefined })); setStep('protocol'); }, []); @@ -175,67 +193,128 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setStep('advanced'); }, []); - const setAdvanced = useCallback((wantsAdvanced: boolean) => { - if (wantsAdvanced) { - setAdvancedSelected(true); - setStep('networkMode'); - } else { - setAdvancedSelected(false); - setConfig(c => ({ - ...c, - networkMode: 'PUBLIC', - subnets: undefined, - securityGroups: undefined, - requestHeaderAllowlist: undefined, - idleRuntimeSessionTimeout: undefined, - maxLifetime: undefined, - })); - setStep('confirm'); - } - }, []); + /** Navigate to the next step after the current one in the steps array */ + const goToNextStep = useCallback( + (afterStep: GenerateStep) => { + // Find the step after afterStep in the current steps array, or fall back to confirm + const idx = steps.indexOf(afterStep); + const next = idx >= 0 ? steps[idx + 1] : undefined; + setStep(next ?? 'confirm'); + }, + [steps] + ); - const setNetworkMode = useCallback((networkMode: NetworkMode) => { - setConfig(c => ({ ...c, networkMode })); - if (networkMode === 'VPC') { - setStep('subnets'); - } else { - setStep('requestHeaderAllowlist'); - } - }, []); + const setDockerfile = useCallback( + (dockerfile: string | undefined) => { + setConfig(c => ({ ...c, dockerfile })); + setTimeout(() => goToNextStep('dockerfile'), 0); + }, + [goToNextStep] + ); + + const setAdvanced = useCallback( + (selectedIds: AdvancedSettingId[]) => { + const selected = new Set(selectedIds); + setAdvancedSettings(selected); + if (selected.size === 0) { + // No advanced settings — reset defaults and go to confirm + setConfig(c => ({ + ...c, + dockerfile: undefined, + networkMode: 'PUBLIC', + subnets: undefined, + securityGroups: undefined, + requestHeaderAllowlist: undefined, + authorizerType: undefined, + jwtConfig: undefined, + idleRuntimeSessionTimeout: undefined, + maxLifetime: undefined, + })); + setStep('confirm'); + } else { + // 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(() => { + // The steps array hasn't updated yet, so we compute the first sub-step manually + if (selected.has('dockerfile') && config.buildType === 'Container') { + setStep('dockerfile'); + } else if (selected.has('network')) { + setStep('networkMode'); + } else if (selected.has('headers')) { + setStep('requestHeaderAllowlist'); + } else if (selected.has('auth')) { + setStep('authorizerType'); + } else if (selected.has('lifecycle')) { + setStep('idleTimeout'); + } else { + setStep('confirm'); + } + }, 0); + } + }, + [config.buildType] + ); + + const setNetworkMode = useCallback( + (networkMode: NetworkMode) => { + setConfig(c => ({ ...c, networkMode })); + if (networkMode === 'VPC') { + setStep('subnets'); + } else { + // Skip subnets/securityGroups, go to next step after networkMode + // We need to find next step after where securityGroups would be, or after networkMode + // Since steps array adapts, just go to next after networkMode + setTimeout(() => goToNextStep('networkMode'), 0); + } + }, + [goToNextStep] + ); const setSubnets = useCallback((subnets: string[]) => { setConfig(c => ({ ...c, subnets })); setStep('securityGroups'); }, []); - const setSecurityGroups = useCallback((securityGroups: string[]) => { - setConfig(c => ({ ...c, securityGroups })); - setStep('requestHeaderAllowlist'); - }, []); + const setSecurityGroups = useCallback( + (securityGroups: string[]) => { + setConfig(c => ({ ...c, securityGroups })); + setTimeout(() => goToNextStep('securityGroups'), 0); + }, + [goToNextStep] + ); - const setRequestHeaderAllowlist = useCallback((requestHeaderAllowlist: string[]) => { - setConfig(c => ({ ...c, requestHeaderAllowlist })); - setStep('authorizerType'); - }, []); + const setRequestHeaderAllowlist = useCallback( + (requestHeaderAllowlist: string[]) => { + setConfig(c => ({ ...c, requestHeaderAllowlist })); + setTimeout(() => goToNextStep('requestHeaderAllowlist'), 0); + }, + [goToNextStep] + ); const skipRequestHeaderAllowlist = useCallback(() => { - setStep('authorizerType'); - }, []); - - const setAuthorizerType = useCallback((authorizerType: RuntimeAuthorizerType) => { - setConfig(c => ({ ...c, authorizerType })); - if (authorizerType === 'CUSTOM_JWT') { - setStep('jwtConfig'); - } else { - setConfig(c => ({ ...c, authorizerType, jwtConfig: undefined })); - setStep('idleTimeout'); - } - }, []); + setTimeout(() => goToNextStep('requestHeaderAllowlist'), 0); + }, [goToNextStep]); + + const setAuthorizerType = useCallback( + (authorizerType: RuntimeAuthorizerType) => { + setConfig(c => ({ ...c, authorizerType })); + if (authorizerType === 'CUSTOM_JWT') { + setStep('jwtConfig'); + } else { + setConfig(c => ({ ...c, authorizerType, jwtConfig: undefined })); + setTimeout(() => goToNextStep('authorizerType'), 0); + } + }, + [goToNextStep] + ); - const setJwtConfig = useCallback((jwtConfig: JwtConfigOptions) => { - setConfig(c => ({ ...c, jwtConfig })); - setStep('idleTimeout'); - }, []); + const setJwtConfig = useCallback( + (jwtConfig: JwtConfigOptions) => { + setConfig(c => ({ ...c, jwtConfig })); + setTimeout(() => goToNextStep('jwtConfig'), 0); + }, + [goToNextStep] + ); const setIdleTimeout = useCallback((value: number | undefined) => { setConfig(c => ({ ...c, idleRuntimeSessionTimeout: value })); @@ -266,7 +345,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setConfig(getDefaultConfig()); setError(null); setSdkSelected(false); - setAdvancedSelected(false); + setAdvancedSettings(new Set()); }, []); /** @@ -290,6 +369,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setProjectName, setLanguage, setBuildType, + setDockerfile, setProtocol, setSdk, setModelProvider, @@ -298,6 +378,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setMemory, setAdvanced, advancedSelected, + advancedSettings, setNetworkMode, setSubnets, setSecurityGroups, diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx new file mode 100644 index 000000000..188f9a694 --- /dev/null +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -0,0 +1,47 @@ +import type { ImportableResourceType } from '../../../commands/import/types'; +import { Panel } from '../../components/Panel'; +import { Screen } from '../../components/Screen'; +import { TextInput } from '../../components/TextInput'; +import { HELP_TEXT } from '../../constants'; + +const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; + +function validateArn(value: string): true | string { + if (!ARN_PATTERN.test(value)) { + return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; + } + return true; +} + +interface ArnInputScreenProps { + resourceType: ImportableResourceType; + onSubmit: (arn: string) => void; + onExit: () => void; +} + +const RESOURCE_TYPE_LABELS: Record = { + runtime: 'Import Runtime', + memory: 'Import Memory', + evaluator: 'Import Evaluator', + 'online-eval': 'Import Online Eval Config', +}; + +export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { + const title = RESOURCE_TYPE_LABELS[resourceType] ?? `Import ${resourceType}`; + const arnResourceType = resourceType === 'online-eval' ? 'online-evaluation-config' : resourceType; + const placeholder = `arn:aws:bedrock-agentcore:::${arnResourceType}/`; + + return ( + + + + + + ); +} diff --git a/src/cli/tui/screens/import/CodePathScreen.tsx b/src/cli/tui/screens/import/CodePathScreen.tsx new file mode 100644 index 000000000..c6a412258 --- /dev/null +++ b/src/cli/tui/screens/import/CodePathScreen.tsx @@ -0,0 +1,20 @@ +import { Panel } from '../../components/Panel'; +import { PathInput } from '../../components/PathInput'; +import { Screen } from '../../components/Screen'; +import { Text } from 'ink'; + +interface CodePathScreenProps { + onSubmit: (codePath: string) => void; + onExit: () => void; +} + +export function CodePathScreen({ onSubmit, onExit }: CodePathScreenProps) { + return ( + + + Path to the directory containing your entrypoint file + + + + ); +} diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx new file mode 100644 index 000000000..ed82962d3 --- /dev/null +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -0,0 +1,202 @@ +import { + IMPORTABLE_RESOURCES, + type ImportResourceResult, + type ImportResult, + type ImportableResourceType, +} from '../../../commands/import/types'; +import { type NextStep, NextSteps } from '../../components/NextSteps'; +import { Panel } from '../../components/Panel'; +import { ErrorPrompt } from '../../components/PromptScreen'; +import { Screen } from '../../components/Screen'; +import { HELP_TEXT } from '../../constants'; +import { ArnInputScreen } from './ArnInputScreen'; +import { CodePathScreen } from './CodePathScreen'; +import { ImportProgressScreen } from './ImportProgressScreen'; +import { ImportSelectScreen, type ImportType } from './ImportSelectScreen'; +import { YamlPathScreen } from './YamlPathScreen'; +import { Box, Text } from 'ink'; +import React, { useState } from 'react'; + +type ImportFlowState = + | { name: 'select-type' } + | { name: 'arn-input'; resourceType: ImportableResourceType } + | { name: 'code-path'; resourceType: 'runtime'; arn: string } + | { name: 'yaml-path' } + | { + name: 'importing'; + importType: ImportType; + arn?: string; + code?: string; + yamlPath?: string; + } + | { + name: 'success'; + importType: ImportType; + result: ImportResourceResult | ImportResult; + } + | { name: 'error'; message: string }; + +const IMPORT_NEXT_STEPS: NextStep[] = [ + { command: 'deploy', label: 'Deploy the imported stack' }, + { command: 'status', label: 'Verify resource status' }, +]; + +interface ImportFlowProps { + onBack: () => void; + onNavigate?: (command: string) => void; +} + +export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { + const [flow, setFlow] = useState({ name: 'select-type' }); + + if (flow.name === 'select-type') { + return ( + { + if ((IMPORTABLE_RESOURCES as readonly string[]).includes(type)) { + setFlow({ name: 'arn-input', resourceType: type as ImportableResourceType }); + } else { + setFlow({ name: 'yaml-path' }); + } + }} + onExit={onBack} + /> + ); + } + + if (flow.name === 'arn-input') { + return ( + { + if (flow.resourceType === 'runtime') { + setFlow({ name: 'code-path', resourceType: 'runtime', arn }); + } else { + setFlow({ + name: 'importing', + importType: flow.resourceType, + arn, + }); + } + }} + onExit={() => setFlow({ name: 'select-type' })} + /> + ); + } + + if (flow.name === 'code-path') { + return ( + { + setFlow({ + name: 'importing', + importType: 'runtime', + arn: flow.arn, + code: codePath, + }); + }} + onExit={() => setFlow({ name: 'arn-input', resourceType: 'runtime' })} + /> + ); + } + + if (flow.name === 'yaml-path') { + return ( + { + setFlow({ + name: 'importing', + importType: 'starter-toolkit', + yamlPath, + }); + }} + onExit={() => setFlow({ name: 'select-type' })} + /> + ); + } + + if (flow.name === 'importing') { + return ( + { + setFlow({ name: 'success', importType: flow.importType, result }); + }} + onError={message => { + setFlow({ name: 'error', message }); + }} + onExit={onBack} + /> + ); + } + + if (flow.name === 'success') { + const result = flow.result; + + return ( + + + + Import successful! + {'resourceType' in result && ( + + + Type: + {result.resourceType} + + + Name: + {result.resourceName} + + {result.resourceId && ( + + ID: + {result.resourceId} + + )} + + )} + {'importedAgents' in result && ( + + {result.importedAgents?.map(agent => ( + + Agent: + {agent} + + ))} + {result.importedMemories?.map(mem => ( + + Memory: + {mem} + + ))} + + )} + + + (onNavigate ? onNavigate(step.command) : onBack())} + onBack={onBack} + /> + + ); + } + + if (flow.name === 'error') { + return ( + setFlow({ name: 'select-type' })} + onExit={onBack} + /> + ); + } + + return null; +} diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx new file mode 100644 index 000000000..bd7096d5f --- /dev/null +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -0,0 +1,100 @@ +import type { ImportResourceResult, ImportResult } from '../../../commands/import/types'; +import { IMPORTABLE_RESOURCES } from '../../../commands/import/types'; +import { Panel } from '../../components/Panel'; +import { Screen } from '../../components/Screen'; +import { type Step, StepProgress } from '../../components/StepProgress'; +import { HELP_TEXT } from '../../constants'; +import type { ImportType } from './ImportSelectScreen'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +interface ImportProgressScreenProps { + importType: ImportType; + arn?: string; + code?: string; + yamlPath?: string; + onSuccess: (result: ImportResourceResult | ImportResult) => void; + onError: (message: string) => void; + onExit: () => void; +} + +export function ImportProgressScreen({ + importType, + arn, + code, + yamlPath, + onSuccess, + onError, + onExit, +}: ImportProgressScreenProps) { + const [steps, setSteps] = useState([{ label: `Importing ${importType}...`, status: 'running' }]); + const started = useRef(false); + + const onProgress = useCallback((message: string) => { + setSteps(prev => { + const updated = prev.map(s => (s.status === 'running' ? { ...s, status: 'success' as const } : s)); + return [...updated, { label: message, status: 'running' as const }]; + }); + }, []); + + useEffect(() => { + if (started.current) return; + started.current = true; + + const run = async () => { + if ((IMPORTABLE_RESOURCES as readonly string[]).includes(importType)) { + const handler = + importType === 'runtime' + ? (await import('../../../commands/import/import-runtime')).handleImportRuntime + : importType === 'memory' + ? (await import('../../../commands/import/import-memory')).handleImportMemory + : importType === 'evaluator' + ? (await import('../../../commands/import/import-evaluator')).handleImportEvaluator + : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; + + const result = await handler({ arn, code, onProgress }); + if (result.success) { + setSteps(prev => prev.map(s => (s.status === 'running' ? { ...s, status: 'success' } : s))); + onSuccess(result); + } else { + setSteps(prev => + prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error } : s)) + ); + onError(result.error ?? 'Import failed'); + } + } else { + // Starter toolkit + const { handleImport } = await import('../../../commands/import/actions'); + const result = await handleImport({ + source: yamlPath!, + onProgress, + }); + if (result.success) { + setSteps(prev => prev.map(s => (s.status === 'running' ? { ...s, status: 'success' } : s))); + onSuccess(result); + } else { + setSteps(prev => + prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error } : s)) + ); + onError(result.error ?? 'Import failed'); + } + } + }; + + void run(); + }, [importType, arn, code, yamlPath, onProgress, onSuccess, onError]); + + const isRunning = steps.some(s => s.status === 'running'); + + return ( + + + + + + ); +} diff --git a/src/cli/tui/screens/import/ImportSelectScreen.tsx b/src/cli/tui/screens/import/ImportSelectScreen.tsx new file mode 100644 index 000000000..21ab114f8 --- /dev/null +++ b/src/cli/tui/screens/import/ImportSelectScreen.tsx @@ -0,0 +1,58 @@ +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'; + +interface ImportSelectItem extends SelectableItem { + id: ImportType; +} + +const IMPORT_OPTIONS: ImportSelectItem[] = [ + { + id: 'runtime', + title: 'Runtime', + description: 'Import an existing AgentCore Runtime from your AWS account', + }, + { + id: 'memory', + title: 'Memory', + description: 'Import an existing AgentCore Memory from your AWS account', + }, + { + id: 'evaluator', + title: 'Evaluator', + description: 'Import an existing AgentCore Evaluator from your AWS account', + }, + { + id: 'online-eval', + title: 'Online Eval Config', + description: 'Import an existing AgentCore Online Evaluation Config from your AWS account', + }, + { + id: 'starter-toolkit', + title: 'From Starter Toolkit', + description: 'Import from a .bedrock_agentcore.yaml configuration file', + }, +]; + +interface ImportSelectScreenProps { + onSelect: (type: ImportType) => void; + onExit: () => void; +} + +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} + /> + ); +} diff --git a/src/cli/tui/screens/import/YamlPathScreen.tsx b/src/cli/tui/screens/import/YamlPathScreen.tsx new file mode 100644 index 000000000..8aa4188e6 --- /dev/null +++ b/src/cli/tui/screens/import/YamlPathScreen.tsx @@ -0,0 +1,26 @@ +import { Panel } from '../../components/Panel'; +import { PathInput } from '../../components/PathInput'; +import { Screen } from '../../components/Screen'; +import { Text } from 'ink'; + +interface YamlPathScreenProps { + onSubmit: (yamlPath: string) => void; + onExit: () => void; +} + +export function YamlPathScreen({ onSubmit, onExit }: YamlPathScreenProps) { + return ( + + + Path to the .bedrock_agentcore.yaml file + + + + ); +} diff --git a/src/cli/tui/screens/import/index.ts b/src/cli/tui/screens/import/index.ts new file mode 100644 index 000000000..b5870c5a2 --- /dev/null +++ b/src/cli/tui/screens/import/index.ts @@ -0,0 +1 @@ +export { ImportFlow } from './ImportFlow'; diff --git a/src/cli/tui/screens/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index 2cb75adb0..f02e40e4e 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -18,25 +18,37 @@ interface InvokeScreenProps { type Mode = 'select-agent' | 'chat' | 'input' | 'token-input'; +interface ColoredLine { + text: string; + color?: string; +} + /** - * Render conversation messages as a single string for scrolling. + * Render conversation as colored lines for scrolling. + * Each line carries its own color so that word-wrapping preserves it. */ -function formatConversation(messages: { role: 'user' | 'assistant'; content: string; isHint?: boolean }[]): string { - const lines: string[] = []; +function formatConversation( + messages: { role: 'user' | 'assistant'; content: string; isHint?: boolean; isExec?: boolean }[] +): ColoredLine[] { + const lines: ColoredLine[] = []; for (const msg of messages) { // Skip empty assistant messages (placeholder before streaming starts) if (msg.role === 'assistant' && !msg.content) continue; - if (msg.role === 'user') { - lines.push(`> ${msg.content}`); + if (msg.role === 'user' && msg.isExec) { + lines.push({ text: msg.content, color: 'magenta' }); + } else if (msg.role === 'user') { + lines.push({ text: `> ${msg.content}`, color: 'blue' }); + } else if (msg.isExec) { + lines.push({ text: msg.content }); } else { - lines.push(msg.content); + lines.push({ text: msg.content, color: 'green' }); } - lines.push(''); // blank line between messages + lines.push({ text: '', color: 'green' }); // blank line between messages } - return lines.join('\n'); + return lines; } /** @@ -81,14 +93,16 @@ function wrapLine(line: string, maxWidth: number): string[] { } /** - * Wrap multi-line text to fit within maxWidth. + * Wrap colored lines for display, preserving color on continuation lines. */ -function wrapText(text: string, maxWidth: number): string[] { - if (!text) return []; - const lines = text.split('\n'); - const wrapped: string[] = []; - for (const line of lines) { - wrapped.push(...wrapLine(line, maxWidth)); +function wrapColoredLines(lines: ColoredLine[], maxWidth: number): ColoredLine[] { + const wrapped: ColoredLine[] = []; + for (const { text, color } of lines) { + for (const subLine of text.split('\n')) { + for (const wrappedLine of wrapLine(subLine, maxWidth)) { + wrapped.push({ text: wrappedLine, color }); + } + } } return wrapped; } @@ -118,10 +132,13 @@ export function InvokeScreen({ setBearerToken, fetchBearerToken, invoke, + execCommand, newSession, fetchMcpTools, } = useInvokeFlow({ initialSessionId, initialUserId, headers: initialHeaders, initialBearerToken }); const [mode, setMode] = useState('select-agent'); + const [isExecInput, setIsExecInput] = useState(false); + const [execInputEmpty, setExecInputEmpty] = useState(true); const [scrollOffset, setScrollOffset] = useState(0); const [userScrolled, setUserScrolled] = useState(false); const { stdout } = useStdout(); @@ -181,11 +198,11 @@ export function InvokeScreen({ const displayHeight = mode === 'input' ? Math.max(3, baseHeight - 2) : baseHeight; const contentWidth = Math.max(40, terminalWidth - 4); - // Format conversation content - const conversationText = useMemo(() => formatConversation(messages), [messages]); + // Format conversation content into colored lines + const coloredLines = useMemo(() => formatConversation(messages), [messages]); - // Wrap text for display - const lines = useMemo(() => wrapText(conversationText, contentWidth), [conversationText, contentWidth]); + // Wrap lines for display, preserving color on continuation lines + const lines = useMemo(() => wrapColoredLines(coloredLines, contentWidth), [coloredLines, contentWidth]); const totalLines = lines.length; const maxScroll = Math.max(0, totalLines - displayHeight); @@ -343,9 +360,11 @@ export function InvokeScreen({ : mode === 'token-input' ? 'Enter confirm · Esc skip' : mode === 'input' - ? isMcp - ? 'Enter send · Esc cancel · "list" to refresh tools' - : 'Enter send · Esc cancel' + ? isExecInput + ? 'Enter run · Esc cancel · Backspace to exit exec mode' + : isMcp + ? 'Enter send · Esc cancel · "list" to refresh tools · ! exec mode' + : 'Enter send · Esc cancel · ! exec mode' : phase === 'invoking' ? '↑↓ scroll' : messages.length > 0 @@ -430,20 +449,22 @@ export function InvokeScreen({ const showThinking = phase === 'invoking' && lastMessage?.role === 'assistant' && !lastMessage.content; return ( - + {/* Conversation display - always visible when there's content */} {messages.length > 0 && ( - {visibleLines.map((line, idx) => { - // Detect user messages (start with "> ") - const isUserMessage = line.startsWith('> '); - return ( - - {line || ' '} - - ); - })} + {visibleLines.map((line, idx) => ( + + {line.text || ' '} + + ))} {/* Thinking indicator - shows while waiting for response to start */} {showThinking && } @@ -464,7 +485,7 @@ export function InvokeScreen({ {mode === 'chat' && phase === 'ready' && messages.length > 0 && ( - > + {isExecInput ? '! ' : '> '} )} {mode === 'chat' && phase === 'ready' && messages.length === 0 && (!isMcp || mcpToolsFetched) && ( @@ -495,31 +516,65 @@ export function InvokeScreen({ )} {mode === 'input' && phase === 'ready' && ( - - > - { - if (text.trim()) { - setMode('chat'); - setUserScrolled(false); - void invoke(text); - } else { - setMode('chat'); + <> + + {isExecInput ? '! ' : '> '} + { - justCancelledRef.current = true; - setMode('chat'); - }} - onUpArrow={() => scrollUp(1)} - onDownArrow={() => scrollDown(1)} - /> - + onChange={(value, setValue) => { + if (!isExecInput && value.startsWith('!')) { + setIsExecInput(true); + const rest = value.slice(1); + setValue(rest); + setExecInputEmpty(!rest); + } else { + setExecInputEmpty(!value); + } + }} + onBackspaceEmpty={isExecInput ? () => setIsExecInput(false) : undefined} + onSubmit={text => { + const trimmed = text.trim(); + if (trimmed) { + setMode('chat'); + setUserScrolled(false); + if (isExecInput) { + void execCommand(trimmed); + } else { + void invoke(text); + } + } else if (!isExecInput) { + setMode('chat'); + } + }} + onCancel={() => { + if (isExecInput) { + setIsExecInput(false); + } else { + justCancelledRef.current = true; + setMode('chat'); + } + }} + onUpArrow={() => scrollUp(1)} + onDownArrow={() => scrollDown(1)} + /> + + {isExecInput && execInputEmpty && ( + + {' '} + Run a shell command in the runtime + + )} + )} diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index f7e4c5b63..a4d7d3bff 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -11,6 +11,7 @@ import type { import { DEFAULT_RUNTIME_USER_ID, type McpToolDef, + executeBashCommand, invokeA2ARuntime, invokeAgentRuntimeStreaming, mcpCallTool, @@ -67,6 +68,7 @@ export interface InvokeFlowState { setBearerToken: (token: string) => void; fetchBearerToken: () => Promise; invoke: (prompt: string) => Promise; + execCommand: (command: string) => Promise; newSession: () => void; fetchMcpTools: () => Promise; } @@ -76,7 +78,9 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const [phase, setPhase] = useState<'loading' | 'ready' | 'invoking' | 'error'>('loading'); const [config, setConfig] = useState(null); const [selectedAgent, setSelectedAgent] = useState(0); - const [messages, setMessages] = useState<{ role: 'user' | 'assistant'; content: string; isHint?: boolean }[]>([]); + const [messages, setMessages] = useState< + { role: 'user' | 'assistant'; content: string; isHint?: boolean; isExec?: boolean }[] + >([]); const [error, setError] = useState(null); const [logFilePath, setLogFilePath] = useState(null); const [sessionId, setSessionId] = useState(null); @@ -369,6 +373,95 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState [config, selectedAgent, phase, sessionId, userId, headers, bearerToken, fetchMcpTools, getMcpInvokeOptions] ); + const execCommand = useCallback( + async (command: string) => { + if (!config || phase === 'invoking') return; + + const agent = config.runtimes[selectedAgent]; + if (!agent) return; + + // Create logger on first invoke or if agent changed + if (!loggerRef.current) { + loggerRef.current = new InvokeLogger({ + agentName: agent.name, + runtimeArn: agent.state.runtimeArn, + region: config.target.region, + sessionId: sessionId ?? undefined, + }); + setLogFilePath(loggerRef.current.getAbsoluteLogPath()); + } + + const logger = loggerRef.current; + + setMessages(prev => [ + ...prev, + { role: 'user', content: `! ${command}`, isExec: true }, + { role: 'assistant', content: '', isExec: true }, + ]); + setPhase('invoking'); + streamingContentRef.current = ''; + + logger.logPrompt(`exec: ${command}`, sessionId ?? undefined, userId); + + try { + const result = await executeBashCommand({ + region: config.target.region, + runtimeArn: agent.state.runtimeArn, + command, + sessionId: sessionId ?? undefined, + headers, + bearerToken: bearerToken || undefined, + }); + + for await (const event of result.stream) { + switch (event.type) { + case 'stdout': + if (event.data) { + streamingContentRef.current += event.data; + } + break; + case 'stderr': + if (event.data) { + streamingContentRef.current += event.data; + } + break; + case 'stop': + if (event.exitCode !== undefined && event.exitCode !== 0) { + streamingContentRef.current += `\n[exit code: ${event.exitCode}${event.status === 'TIMED_OUT' ? ' (timed out)' : ''}]`; + } + break; + } + const currentContent = streamingContentRef.current; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { ...updated[lastIdx], content: currentContent }; + } + return updated; + }); + } + + logger.logResponse(streamingContentRef.current); + setPhase('ready'); + } catch (err) { + const errMsg = getErrorMessage(err); + logger.logError(err, 'exec command failed'); + + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { ...updated[lastIdx], content: `Error: ${errMsg}` }; + } + return updated; + }); + setPhase('ready'); + } + }, + [config, selectedAgent, phase, sessionId, userId, headers, bearerToken] + ); + const newSession = useCallback(() => { const newId = generateSessionId(); setSessionId(newId); @@ -400,6 +493,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState setBearerToken, fetchBearerToken, invoke, + execCommand, newSession, fetchMcpTools, }; diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx index 92c56d90e..d5322e9ed 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx @@ -48,12 +48,17 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, const result = await listEvaluators({ region }); if (cancelled) return; - const items: EvaluatorItem[] = result.evaluators.map(e => ({ - arn: e.evaluatorArn, - name: e.evaluatorName, - type: e.evaluatorType, - description: e.description, - })); + // 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 agentNames = projectSpec.runtimes.map(a => a.name); diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx index 00dbe741a..d4716d4c0 100644 --- a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -210,13 +210,11 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: isActive: isAgentStep, }); - const evaluatorNav = useMultiSelectNavigation({ + const evaluatorNav = useListNavigation({ items: evaluatorItems, - getId: item => item.id, - onConfirm: ids => wizard.setEvaluators(ids), + onSelect: item => wizard.setEvaluators([item.id]), onExit: () => wizard.goBack(), isActive: isEvaluatorStep, - requireSelection: true, }); const inputSourceNav = useListNavigation({ @@ -260,7 +258,7 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: // ── Help text ───────────────────────────────────────────────────────────── const helpText = isEvaluatorStep - ? HELP_TEXT.MULTI_SELECT + ? HELP_TEXT.NAVIGATE_SELECT : isSessionsStep ? sessionPhase === 'loading' ? '' @@ -282,10 +280,15 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: const confirmFields = [ { label: 'Type', value: isSystemPrompt ? 'System Prompt' : 'Tool Description' }, { label: 'Agent', value: wizard.config.agent }, - { - label: 'Evaluator(s)', - value: wizard.config.evaluators.map(e => (e.includes('/') ? e.split('/').pop()! : e)).join(', ') || '(none)', - }, + ...(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}` : 'Inline' }, { label: 'Traces', @@ -331,14 +334,12 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: )} {isEvaluatorStep && ( - )} diff --git a/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx index ed34f69f3..86d71609a 100644 --- a/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx @@ -4,7 +4,7 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import React, { useMemo } from 'react'; -export type RecommendationsHubView = 'run-recommendation' | 'recommendation-history' | 'list-recommendations'; +export type RecommendationsHubView = 'run-recommendation' | 'recommendation-history'; interface RecommendationsHubScreenProps { onSelect: (view: RecommendationsHubView) => void; @@ -24,11 +24,6 @@ export function RecommendationsHubScreen({ onSelect, onExit }: RecommendationsHu title: 'Recommendation History', description: 'View past recommendation results (local)', }, - { - id: 'list-recommendations', - title: 'List Recommendations', - description: 'List recommendations from the API', - }, ], [] ); diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts index 012753408..6f9416c82 100644 --- a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -12,11 +12,20 @@ function getAllSteps( inputSource: RecommendationInputSourceKind, traceSource: TraceSourceKind ): RecommendationStep[] { - const steps: RecommendationStep[] = ['type', 'agent', 'evaluator', 'inputSource']; + const steps: RecommendationStep[] = ['type', 'agent']; - // Content step for inline/file; skip for config-bundle - if (inputSource === 'inline' || inputSource === 'file') { - steps.push('content'); + // Evaluator step only for system prompt recommendations (tool desc API does not accept evaluators) + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') { + steps.push('evaluator'); + } + + // For system prompt: ask input source and content + // For tool description: skip inputSource/content (tools step handles it) + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') { + steps.push('inputSource'); + if (inputSource === 'inline' || inputSource === 'file') { + steps.push('content'); + } } // Tools step only for tool description recommendations diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 1d3d2c059..1c61a0278 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -16,11 +16,12 @@ import { Panel, Screen, StepIndicator, + StepProgress, TextInput, WizardMultiSelect, WizardSelect, } from '../../components'; -import type { SelectableItem } 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'; @@ -51,7 +52,7 @@ const STEP_LABELS: Record = { type FlowState = | { name: 'loading' } | { name: 'wizard'; agents: AgentItem[]; evaluators: EvaluatorItem[] } - | { name: 'running'; config: BatchEvalConfig; progress: string } + | { name: 'running'; config: BatchEvalConfig; steps: Step[]; elapsed: number } | { name: 'results'; result: RunBatchEvaluationCommandResult } | { name: 'creds-error'; message: string } | { name: 'error'; message: string }; @@ -140,7 +141,12 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { }, [flow.name]); const handleWizardComplete = useCallback((config: BatchEvalConfig) => { - setFlow({ name: 'running', config, progress: 'Starting batch evaluation...' }); + 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 @@ -149,6 +155,16 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { 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 { @@ -156,11 +172,21 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { agent: config.agent, evaluators: config.evaluators, name: config.name || undefined, - onProgress: (_status, message) => { - if (!cancelled) setFlow(prev => (prev.name === 'running' ? { ...prev, progress: message } : prev)); + 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 }; + }); }, }); + clearInterval(timer); if (cancelled) return; // Save results locally @@ -173,18 +199,47 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { } 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' }); 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 }); } catch (err) { - if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(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 @@ -212,9 +267,24 @@ 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`; + return ( - + + + + Agent: {flow.config.agent} + {' '} + Evaluators: {flow.config.evaluatorNames.join(', ')} + {' '} + ({timeStr}) + + + + ); } @@ -419,8 +489,12 @@ function ResultsView({ result, onRunAnother, onExit }: ResultsViewProps) { isActive: true, }); - // Group results by evaluator + 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) ?? []; @@ -428,7 +502,7 @@ function ResultsView({ result, onRunAnother, onExit }: ResultsViewProps) { map.set(r.evaluatorId, group); } return map; - }, [result.results]); + }, [result.results, summaries]); return ( @@ -446,7 +520,34 @@ function ResultsView({ result, onRunAnother, onExit }: ResultsViewProps) { )} - {result.results.length > 0 ? ( + {evalRes?.totalSessions != null && ( + + Sessions: {evalRes.totalSessions} total + {evalRes.sessionsCompleted != null && , {evalRes.sessionsCompleted} completed} + {evalRes.sessionsFailed ? , {evalRes.sessionsFailed} 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]) => { diff --git a/src/cli/tui/utils/commands.ts b/src/cli/tui/utils/commands.ts index d14373c3a..4976e7706 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -12,7 +12,7 @@ export interface CommandMeta { /** * Commands hidden from TUI entirely (meta commands). */ -const HIDDEN_FROM_TUI = ['help', 'import'] as const; +const HIDDEN_FROM_TUI = ['help'] as const; /** * Commands that are CLI-only (shown but marked as requiring CLI invocation). diff --git a/src/lib/__tests__/constants.test.ts b/src/lib/__tests__/constants.test.ts index 72f823be6..21b952b69 100644 --- a/src/lib/__tests__/constants.test.ts +++ b/src/lib/__tests__/constants.test.ts @@ -1,4 +1,5 @@ -import { getArtifactZipName } from '../constants.js'; +import { getArtifactZipName, getDockerfilePath } from '../constants.js'; +import { join } from 'path'; import { describe, expect, it } from 'vitest'; describe('getArtifactZipName', () => { @@ -18,3 +19,29 @@ describe('getArtifactZipName', () => { expect(getArtifactZipName('agent.tar')).toBe('agent.tar.zip'); }); }); + +describe('getDockerfilePath', () => { + it('returns default Dockerfile when no custom name given', () => { + expect(getDockerfilePath('/app/code')).toBe(join('/app/code', 'Dockerfile')); + }); + + it('returns custom dockerfile name joined to code location', () => { + expect(getDockerfilePath('/app/code', 'Dockerfile.gpu')).toBe(join('/app/code', 'Dockerfile.gpu')); + }); + + it('rejects forward slash in dockerfile name', () => { + expect(() => getDockerfilePath('/app/code', '../Dockerfile')).toThrow(/Invalid dockerfile name/); + }); + + it('rejects backslash in dockerfile name', () => { + expect(() => getDockerfilePath('/app/code', 'Dockerfile\\..\\secret')).toThrow(/Invalid dockerfile name/); + }); + + it('rejects dot-dot traversal in dockerfile name', () => { + expect(() => getDockerfilePath('/app/code', '..')).toThrow(/Invalid dockerfile name/); + }); + + it('rejects path/to/Dockerfile', () => { + expect(() => getDockerfilePath('/app/code', 'path/to/Dockerfile')).toThrow(/Invalid dockerfile name/); + }); +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fac7f0a9c..8916c6052 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -59,7 +59,13 @@ export const START_HINTS: Record = { /** * Get the Dockerfile path for a given code location. + * @param codeLocation - Directory containing the Dockerfile + * @param dockerfile - Custom Dockerfile name (default: 'Dockerfile') */ -export function getDockerfilePath(codeLocation: string): string { - return join(codeLocation, DOCKERFILE_NAME); +export function getDockerfilePath(codeLocation: string, dockerfile?: string): string { + const name = dockerfile ?? DOCKERFILE_NAME; + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + throw new Error(`Invalid dockerfile name: must be a filename without path separators or traversal`); + } + return join(codeLocation, name); } diff --git a/src/lib/packaging/__tests__/container.test.ts b/src/lib/packaging/__tests__/container.test.ts index 0dc6f285a..cb5685dbf 100644 --- a/src/lib/packaging/__tests__/container.test.ts +++ b/src/lib/packaging/__tests__/container.test.ts @@ -178,6 +178,38 @@ describe('ContainerPackager', () => { expect(result.artifactPath).toBe('finch://agentcore-package-agent'); }); + it('uses custom dockerfile name from spec', async () => { + mockResolveCodeLocation.mockReturnValue('/resolved/src'); + mockExistsSync.mockReturnValue(true); + mockSpawnSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which' && args[0] === 'docker') return { status: 0 }; + if (cmd === 'docker' && args[0] === '--version') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'build') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'image') return { status: 0, stdout: Buffer.from('1000') }; + return { status: 1 }; + }); + + const specWithDockerfile = { ...baseSpec, dockerfile: 'Dockerfile.gpu' }; + await packager.pack(specWithDockerfile as any); + + // Verify build was called with custom dockerfile path + const buildCall = mockSpawnSync.mock.calls.find( + (c: unknown[]) => c[0] === 'docker' && (c[1] as string[])[0] === 'build' + ); + expect(buildCall).toBeDefined(); + const buildArgs = buildCall![1] as string[]; + const fIdx = buildArgs.indexOf('-f'); + expect(buildArgs[fIdx + 1]).toBe('/resolved/src/Dockerfile.gpu'); + }); + + it('rejects when custom dockerfile not found', async () => { + mockResolveCodeLocation.mockReturnValue('/resolved/src'); + mockExistsSync.mockReturnValue(false); + + const specWithDockerfile = { ...baseSpec, dockerfile: 'Dockerfile.custom' }; + await expect(packager.pack(specWithDockerfile as any)).rejects.toThrow('Dockerfile.custom not found'); + }); + it('detects podman runtime last', async () => { mockResolveCodeLocation.mockReturnValue('/resolved/src'); mockExistsSync.mockReturnValue(true); diff --git a/src/lib/packaging/container.ts b/src/lib/packaging/container.ts index 79cf7325e..a166a08f3 100644 --- a/src/lib/packaging/container.ts +++ b/src/lib/packaging/container.ts @@ -1,12 +1,11 @@ import type { AgentEnvSpec } from '../../schema'; -import { CONTAINER_RUNTIMES, DOCKERFILE_NAME, ONE_GB } from '../constants'; +import { CONTAINER_RUNTIMES, DOCKERFILE_NAME, ONE_GB, getDockerfilePath } from '../constants'; import { getUvBuildArgs } from './build-args'; import { PackagingError } from './errors'; import { resolveCodeLocation } from './helpers'; import type { ArtifactResult, PackageOptions, RuntimePackager } from './types/packaging'; import { spawnSync } from 'child_process'; import { existsSync } from 'fs'; -import { join } from 'path'; /** * Detect container runtime synchronously. @@ -36,12 +35,14 @@ export class ContainerPackager implements RuntimePackager { const agentName = options.agentName ?? spec.name; const configBaseDir = options.artifactDir ?? options.projectRoot ?? process.cwd(); const codeLocation = resolveCodeLocation(spec.codeLocation, configBaseDir); - const dockerfilePath = join(codeLocation, DOCKERFILE_NAME); + const dockerfilePath = getDockerfilePath(codeLocation, spec.dockerfile); // Preflight: Dockerfile must exist if (!existsSync(dockerfilePath)) { return Promise.reject( - new PackagingError(`Dockerfile not found at ${dockerfilePath}. Container agents require a Dockerfile.`) + new PackagingError( + `${spec.dockerfile ?? DOCKERFILE_NAME} not found at ${dockerfilePath}. Container agents require a Dockerfile.` + ) ); } diff --git a/src/lib/packaging/index.ts b/src/lib/packaging/index.ts index b18806d36..e90413962 100644 --- a/src/lib/packaging/index.ts +++ b/src/lib/packaging/index.ts @@ -67,7 +67,7 @@ export function getContainerPackager(): RuntimePackager { * Automatically selects the appropriate packager based on build type and runtime version. */ export async function packRuntime(spec: AgentEnvSpec, options?: PackageOptions): Promise { - const packager = spec.build === 'Container' ? getContainerPackager() : getRuntimePackager(spec.runtimeVersion); + const packager = spec.build === 'Container' ? getContainerPackager() : getRuntimePackager(spec.runtimeVersion!); return packager.pack(spec, options); } diff --git a/src/lib/packaging/node.ts b/src/lib/packaging/node.ts index 2618dea05..c02e1511f 100644 --- a/src/lib/packaging/node.ts +++ b/src/lib/packaging/node.ts @@ -54,7 +54,7 @@ export class NodeCodeZipPackager implements RuntimePackager { throw new PackagingError('Node packager only supports CodeZip build type.'); } - if (!isNodeRuntimeVersion(spec.runtimeVersion)) { + if (!isNodeRuntimeVersion(spec.runtimeVersion!)) { throw new PackagingError(`Node packager only supports Node runtimes. Received: ${spec.runtimeVersion}`); } diff --git a/src/lib/packaging/python.ts b/src/lib/packaging/python.ts index 8e508b452..5bb470eae 100644 --- a/src/lib/packaging/python.ts +++ b/src/lib/packaging/python.ts @@ -60,7 +60,7 @@ export class PythonCodeZipPackager implements RuntimePackager { throw new PackagingError('Python packager only supports CodeZip build type.'); } - if (!isPythonRuntimeVersion(spec.runtimeVersion)) { + if (!isPythonRuntimeVersion(spec.runtimeVersion!)) { throw new PackagingError(`Python packager only supports Python runtimes. Received: ${spec.runtimeVersion}`); } diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index 83a40b034..7fcda0245 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -46,7 +46,8 @@ interface AgentEnvSpec { build: BuildType; entrypoint: string; // @regex ^[a-zA-Z0-9_][a-zA-Z0-9_/.-]*\.(py|ts|js)(:[a-zA-Z_][a-zA-Z0-9_]*)?$ e.g. "main.py:handler" or "index.ts" codeLocation: string; // Directory path - runtimeVersion: RuntimeVersion; + dockerfile?: string; // Custom Dockerfile name for Container builds (default: 'Dockerfile'). Must be a filename, not a path. + runtimeVersion?: RuntimeVersion; envVars?: EnvVar[]; networkMode?: NetworkMode; // default 'PUBLIC' networkConfig?: NetworkConfig; // Required when networkMode is 'VPC' diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 7fcbc17c3..477c2489a 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -405,6 +405,88 @@ describe('LifecycleConfigurationSchema', () => { }); }); +describe('AgentEnvSpecSchema - dockerfile', () => { + const validContainerAgent = { + name: 'ContainerAgent', + build: 'Container', + entrypoint: 'main.py', + codeLocation: './agents/container', + }; + + const validCodeZipAgent = { + name: 'CodeZipAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/test', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts Container agent with custom dockerfile', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'Dockerfile.gpu' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.dockerfile).toBe('Dockerfile.gpu'); + } + }); + + it('accepts Container agent without dockerfile (optional)', () => { + const result = AgentEnvSpecSchema.safeParse(validContainerAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.dockerfile).toBeUndefined(); + } + }); + + 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('rejects dockerfile on CodeZip builds', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validCodeZipAgent, dockerfile: 'Dockerfile.custom' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('only allowed for Container'))).toBe(true); + } + }); + + 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 empty string dockerfile', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: '' }).success).toBe(false); + }); + + it('rejects shell metacharacters in dockerfile', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'Dockerfile;rm -rf /' }).success).toBe( + false + ); + }); + + it('rejects dockerfile exceeding 255 characters', () => { + const longName = 'D' + 'a'.repeat(255); + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: longName }).success).toBe(false); + }); + + it('accepts dockerfile at exactly 255 characters', () => { + const maxName = 'D' + 'a'.repeat(254); + 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); + } + ); +}); + describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { const validAgent = { name: 'TestAgent', diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index a0e3f048f..fc2c54d1e 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -178,10 +178,19 @@ export type LifecycleConfiguration = z.infer; diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index be0882042..b47459748 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -34,7 +34,15 @@ export { EvaluationLevelSchema }; export type { MemoryStrategy, MemoryStrategyType } from './primitives/memory'; export type { OnlineEvalConfig } from './primitives/online-eval-config'; export { OnlineEvalConfigSchema, OnlineEvalConfigNameSchema } from './primitives/online-eval-config'; -export type { EvaluationLevel, EvaluatorConfig, LlmAsAJudgeConfig, RatingScale } from './primitives/evaluator'; +export type { + CodeBasedConfig, + EvaluationLevel, + EvaluatorConfig, + ExternalCodeBasedConfig, + LlmAsAJudgeConfig, + ManagedCodeBasedConfig, + RatingScale, +} from './primitives/evaluator'; export { BedrockModelIdSchema, isValidBedrockModelId, EvaluatorNameSchema } from './primitives/evaluator'; export { ConfigBundleSchema }; export type { ComponentConfiguration, ComponentConfigurationMap, ConfigBundle } from './primitives/config-bundle'; @@ -107,6 +115,8 @@ export const MemorySchema = z.object({ ) ), tags: TagsSchema.optional(), + encryptionKeyArn: z.string().optional(), + executionRoleArn: z.string().optional(), }); export type Memory = z.infer; @@ -312,6 +322,15 @@ export const AgentCoreProjectSpecSchema = z message: `Online eval config "${config.name}" references unknown evaluator "${evalName}"`, }); } + + // Block code-based evaluators in online eval configs + const evaluator = spec.evaluators.find(e => 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/primitives/evaluator.ts b/src/schema/schemas/primitives/evaluator.ts index ced23b530..1cd487151 100644 --- a/src/schema/schemas/primitives/evaluator.ts +++ b/src/schema/schemas/primitives/evaluator.ts @@ -74,11 +74,49 @@ export const LlmAsAJudgeConfigSchema = z.object({ export type LlmAsAJudgeConfig = z.infer; // ============================================================================ -// Evaluator Config +// Code-Based Evaluator Config // ============================================================================ -export const EvaluatorConfigSchema = z.object({ - llmAsAJudge: LlmAsAJudgeConfigSchema, +export const ManagedCodeBasedConfigSchema = z.object({ + codeLocation: z.string().min(1), + entrypoint: z.string().min(1).default('lambda_function.handler'), + timeoutSeconds: z.number().int().min(1).max(300).default(60), + additionalPolicies: z.array(z.string().min(1)).optional(), }); +export type ManagedCodeBasedConfig = z.infer; + +// eslint-disable-next-line security/detect-unsafe-regex -- anchored pattern, no backtracking risk +const LAMBDA_ARN_PATTERN = /^arn:aws[a-z-]*:lambda:[a-z0-9-]+:\d{12}:function:.+$/; + +export const ExternalCodeBasedConfigSchema = z.object({ + lambdaArn: z.string().min(1).regex(LAMBDA_ARN_PATTERN, 'Must be a valid Lambda function ARN'), +}); + +export type ExternalCodeBasedConfig = z.infer; + +export const CodeBasedConfigSchema = z + .object({ + managed: ManagedCodeBasedConfigSchema.optional(), + external: ExternalCodeBasedConfigSchema.optional(), + }) + .refine(config => Boolean(config.managed) !== Boolean(config.external), { + message: 'Code-based config must have either managed or external, not both', + }); + +export type CodeBasedConfig = z.infer; + +// ============================================================================ +// Evaluator Config +// ============================================================================ + +export const EvaluatorConfigSchema = z + .object({ + llmAsAJudge: LlmAsAJudgeConfigSchema.optional(), + codeBased: CodeBasedConfigSchema.optional(), + }) + .refine(config => Boolean(config.llmAsAJudge) !== Boolean(config.codeBased), { + message: 'Config must have either llmAsAJudge or codeBased, not both', + }); + export type EvaluatorConfig = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index 0549a2cec..e14a0f248 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -8,23 +8,29 @@ export { } from './memory'; export type { + CategoricalRating, + CodeBasedConfig, EvaluationLevel, EvaluatorConfig, + ExternalCodeBasedConfig, LlmAsAJudgeConfig, - RatingScale, + ManagedCodeBasedConfig, NumericalRating, - CategoricalRating, + RatingScale, } from './evaluator'; export { BedrockModelIdSchema, - isValidBedrockModelId, + CategoricalRatingSchema, + CodeBasedConfigSchema, EvaluationLevelSchema, EvaluatorConfigSchema, EvaluatorNameSchema, + ExternalCodeBasedConfigSchema, + isValidBedrockModelId, LlmAsAJudgeConfigSchema, - RatingScaleSchema, + ManagedCodeBasedConfigSchema, NumericalRatingSchema, - CategoricalRatingSchema, + RatingScaleSchema, } from './evaluator'; export type { OnlineEvalConfig } from './online-eval-config'; 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 17/64] 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 8eaaf894d4218234be5b6a0e5e9f4fe6e70878af Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:32:22 -0400 Subject: [PATCH 18/64] 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. --- src/cli/aws/agentcore-config-bundles.ts | 2 +- src/cli/commands/config-bundle/command.tsx | 8 ++++---- src/cli/operations/eval/run-batch-evaluation.ts | 15 ++++++++++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index dae918bf1..0782a90d8 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -145,7 +145,7 @@ export interface GetConfigurationBundleVersionResult { export interface ListConfigurationBundleVersionsFilter { branchName?: string; latestPerBranch?: boolean; - createdBy?: string[]; + createdByName?: string; } export interface ListConfigurationBundleVersionsOptions { diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx index 41be95427..ae2274f7c 100644 --- a/src/cli/commands/config-bundle/command.tsx +++ b/src/cli/commands/config-bundle/command.tsx @@ -47,7 +47,7 @@ async function handleVersions(options: { bundle: string; branch?: string; latestPerBranch?: boolean; - createdBy?: string[]; + createdBy?: string; region?: string; json?: boolean; }) { @@ -57,7 +57,7 @@ async function handleVersions(options: { const filter: ListConfigurationBundleVersionsFilter = {}; if (options.branch) filter.branchName = options.branch; if (options.latestPerBranch) filter.latestPerBranch = true; - if (options.createdBy?.length) filter.createdBy = options.createdBy; + if (options.createdBy) filter.createdByName = options.createdBy; const hasFilter = Object.keys(filter).length > 0; // Paginate to collect all versions @@ -116,7 +116,7 @@ export const registerConfigBundle = (program: Command) => { .requiredOption('--bundle ', 'Bundle name') .option('--branch ', 'Filter by branch name') .option('--latest-per-branch', 'Show only the latest version per branch') - .option('--created-by ', 'Filter by creator (e.g. "user", "recommendation")') + .option('--created-by ', 'Filter by creator name (e.g. "user", "recommendation")') .option('--region ', 'AWS region override') .option('--json', 'Output as JSON') .action( @@ -124,7 +124,7 @@ export const registerConfigBundle = (program: Command) => { bundle: string; branch?: string; latestPerBranch?: boolean; - createdBy?: string[]; + createdBy?: string; region?: string; json?: boolean; }) => { diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index 4c33192d6..d561cb345 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -112,6 +112,19 @@ export async function runBatchEvaluationCommand( logger?.log(`Log group: ${runtimeLogGroup}`); logger?.endStep('success'); + // 2b. Resolve evaluator names to deployed IDs + const targetResources = Object.values(deployedState.targets).find(t => t.resources?.runtimes?.[agent])?.resources; + const resolvedEvaluators = evaluators.map(name => { + if (name.startsWith('Builtin.')) return name; + const deployed = targetResources?.evaluators?.[name]; + if (deployed?.evaluatorId) { + logger?.log(`Resolved evaluator "${name}" → ${deployed.evaluatorId}`); + return deployed.evaluatorId; + } + logger?.log(`Evaluator "${name}" not found in deployed state, passing as-is`, 'warn'); + return name; + }); + // 3. Start the batch evaluation logger?.startStep('Start batch evaluation'); const evalName = options.name ?? `${projectSpec.name}_${agent}_${Date.now()}`; @@ -122,7 +135,7 @@ export async function runBatchEvaluationCommand( region, name: evalName, evaluationConfig: { - evaluators: evaluators.map(id => ({ evaluatorId: id })), + evaluators: resolvedEvaluators.map(id => ({ evaluatorId: id })), }, sessionSource: { cloudWatchSource: { From 00f41455a849e50b10f9ad326ae63ce7ce4831c1 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:51:10 -0400 Subject: [PATCH 19/64] 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. --- src/cli/commands/run/command.tsx | 8 ++++---- src/cli/tui/screens/recommendation/RecommendationFlow.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index dd72c1b7e..334082f62 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -242,8 +242,8 @@ export const registerRun = (program: Command) => { .option('--bundle-name ', 'Read current content from a deployed config bundle') .option('--bundle-version ', 'Config bundle version (used with --bundle-name)') .option( - '--tools ', - 'Tool name:description pairs, comma-separated (e.g. "search:Searches the web,calc:Does math")' + '--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') @@ -260,7 +260,7 @@ export const registerRun = (program: Command) => { inline?: string; bundleName?: string; bundleVersion?: string; - tools?: string; + tools?: string[]; spansFile?: string; lookback: string; sessionId?: string[]; @@ -329,7 +329,7 @@ export const registerRun = (program: Command) => { inlineContent: cliOptions.inline, bundleName: cliOptions.bundleName, bundleVersion: cliOptions.bundleVersion, - tools: cliOptions.tools ? cliOptions.tools.split(',').map(t => t.trim()) : undefined, + tools: cliOptions.tools, lookbackDays: parseInt(cliOptions.lookback, 10), sessionIds: cliOptions.sessionId, spansFile: cliOptions.spansFile, diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index 928ab4428..ae307b95a 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -129,7 +129,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { promptFile: config.inputSource === 'file' ? config.content : undefined, tools: config.tools ? config.tools - .split(',') + .split(/,(?=[a-zA-Z0-9_\-.]+:)/) .map(t => t.trim()) .filter(Boolean) : undefined, From dba694172c2d6eb9e5afbf465833e9ac20d658b8 Mon Sep 17 00:00:00 2001 From: "T.J Ariyawansa" Date: Wed, 8 Apr 2026 18:13:05 -0400 Subject: [PATCH 20/64] 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 --- integ-tests/add-remove-ab-test.test.ts | 165 ++++++ package-lock.json | 460 +++++++++-------- package.json | 1 + .../aws/__tests__/agentcore-ab-tests.test.ts | 343 +++++++++++++ src/cli/aws/agentcore-ab-tests.ts | 358 ++++++++++++++ src/cli/cli.ts | 4 + src/cli/commands/abtest/command.ts | 156 ++++++ src/cli/commands/abtest/index.ts | 1 + src/cli/commands/deploy/actions.ts | 30 ++ .../commands/logs/__tests__/action.test.ts | 4 + src/cli/commands/pause/command.tsx | 125 +++++ src/cli/commands/remove/command.tsx | 1 + src/cli/commands/remove/types.ts | 3 +- src/cli/commands/status/action.ts | 12 +- src/cli/commands/stop/index.ts | 2 +- .../__tests__/checks-extended.test.ts | 10 + src/cli/logging/remove-logger.ts | 3 +- .../agent/generate/write-agent-to-project.ts | 1 + .../__tests__/post-deploy-ab-tests.test.ts | 437 ++++++++++++++++ .../operations/deploy/post-deploy-ab-tests.ts | 468 ++++++++++++++++++ .../operations/dev/__tests__/config.test.ts | 21 + src/cli/primitives/ABTestPrimitive.ts | 284 +++++++++++ .../__tests__/ABTestPrimitive.test.ts | 244 +++++++++ .../__tests__/GatewayPrimitive.test.ts | 1 + .../primitives/__tests__/auth-utils.test.ts | 1 + src/cli/primitives/index.ts | 2 + src/cli/primitives/registry.ts | 3 + src/cli/project.ts | 1 + src/cli/tui/App.tsx | 8 + src/cli/tui/components/ResourceGraph.tsx | 26 +- src/cli/tui/copy.ts | 9 +- src/cli/tui/hooks/useCreateABTest.ts | 73 +++ src/cli/tui/hooks/useRemove.ts | 20 + .../screens/ab-test/ABTestDetailScreen.tsx | 247 +++++++++ .../screens/ab-test/ABTestPickerScreen.tsx | 87 ++++ src/cli/tui/screens/ab-test/AddABTestFlow.tsx | 157 ++++++ .../tui/screens/ab-test/AddABTestScreen.tsx | 247 +++++++++ .../screens/ab-test/RemoveABTestScreen.tsx | 26 + .../tui/screens/ab-test/VariantConfigForm.tsx | 248 ++++++++++ .../__tests__/useAddABTestWizard.test.tsx | 212 ++++++++ src/cli/tui/screens/ab-test/index.ts | 4 + src/cli/tui/screens/ab-test/types.ts | 38 ++ .../tui/screens/ab-test/useAddABTestWizard.ts | 139 ++++++ src/cli/tui/screens/add/AddFlow.tsx | 18 + src/cli/tui/screens/add/AddScreen.tsx | 1 + src/cli/tui/screens/deploy/DeployScreen.tsx | 15 + src/cli/tui/screens/deploy/useDeployFlow.ts | 46 ++ src/cli/tui/screens/remove/RemoveFlow.tsx | 105 +++- src/cli/tui/screens/remove/RemoveScreen.tsx | 11 + .../remove/__tests__/RemoveScreen.test.tsx | 53 ++ src/cli/tui/utils/commands.ts | 2 +- src/schema/schemas/agentcore-project.ts | 11 + src/schema/schemas/deployed-state.ts | 16 + .../primitives/__tests__/ab-test.test.ts | 228 +++++++++ src/schema/schemas/primitives/ab-test.ts | 83 ++++ src/schema/schemas/primitives/index.ts | 21 + 56 files changed, 5070 insertions(+), 222 deletions(-) create mode 100644 integ-tests/add-remove-ab-test.test.ts create mode 100644 src/cli/aws/__tests__/agentcore-ab-tests.test.ts create mode 100644 src/cli/aws/agentcore-ab-tests.ts create mode 100644 src/cli/commands/abtest/command.ts create mode 100644 src/cli/commands/abtest/index.ts create mode 100644 src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts create mode 100644 src/cli/operations/deploy/post-deploy-ab-tests.ts create mode 100644 src/cli/primitives/ABTestPrimitive.ts create mode 100644 src/cli/primitives/__tests__/ABTestPrimitive.test.ts create mode 100644 src/cli/tui/hooks/useCreateABTest.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/VariantConfigForm.tsx create mode 100644 src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.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/schema/schemas/primitives/__tests__/ab-test.test.ts create mode 100644 src/schema/schemas/primitives/ab-test.ts 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..d80189028 --- /dev/null +++ b/integ-tests/add-remove-ab-test.test.ts @@ -0,0 +1,165 @@ +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({ noAgent: true }); + }); + + 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 --gateway-arn when --name is provided', async () => { + const json = await runFailure(['add', 'ab-test', '--name', 'Test1', '--json'], project.projectPath); + expect(json.error).toContain('--gateway-arn'); + }); + + it('adds ab-test with all required flags', async () => { + const json = await runSuccess( + [ + 'add', + 'ab-test', + '--name', + 'MyIntegTest', + '--gateway-arn', + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-abc', + '--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', + '--gateway-arn', + 'arn:gw', + '--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', + '--gateway-arn', + 'arn:gw', + '--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/package-lock.json b/package-lock.json index e00dfdcfd..5d85d39c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@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-iam": "^3.1025.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/client-sts": "^3.893.0", @@ -110,9 +111,9 @@ } }, "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.273", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.273.tgz", - "integrity": "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg==", + "version": "2.2.263", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.263.tgz", + "integrity": "sha512-X9JvcJhYcb7PHs8R7m4zMablO5C9PGb/hYfLnxds9h/rKJu6l7MiXE/SabCibuehxPnuO/vk+sVVJiUWrccarQ==", "dev": true, "license": "Apache-2.0" }, @@ -826,24 +827,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1023.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1023.0.tgz", - "integrity": "sha512-bjHJzc7e4PF73aequBzyfZY9l7+hXd0rK50vXk5IChExhlp91C8ooee9Y5nk6oEV4QO54gX0ZRukmUIvE6Be4w==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1020.0.tgz", + "integrity": "sha512-lHcrS7raDibEs8zGO5tMCNCikvbvibmgHfZZL1pvz89Qdg+aQYtknhfd4kyKuMH8zrJ2re0AWVgxCpBSfGqJNA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", - "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/credential-provider-node": "^3.972.28", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/middleware-user-agent": "^3.972.27", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.14", + "@aws-sdk/util-user-agent-node": "^3.973.13", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/eventstream-serde-browser": "^4.2.12", @@ -854,7 +855,7 @@ "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-retry": "^4.4.45", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", @@ -870,7 +871,7 @@ "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.13", + "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -880,24 +881,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1023.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1023.0.tgz", - "integrity": "sha512-O7YccWAoPDvxp853aklKmslJsau5pkN1UtieMDSy6G/71b6IGdqxNW6OEqBRbGocQoCQESzu6CbskYncOOfd0Q==", + "version": "3.1020.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1020.0.tgz", + "integrity": "sha512-ucH65hymNXnkL7YtNt5OHV6cw61m2bVxbB5RVsCeN7wAtLhbFqK5yC+YIG5iQNZSs97TJ5AchCKDSj01PcMkQQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", - "@aws-sdk/credential-provider-node": "^3.972.29", + "@aws-sdk/credential-provider-node": "^3.972.28", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.28", + "@aws-sdk/middleware-user-agent": "^3.972.27", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.14", + "@aws-sdk/util-user-agent-node": "^3.973.13", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", @@ -905,7 +906,7 @@ "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-retry": "^4.4.45", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", @@ -921,7 +922,7 @@ "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.13", + "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" @@ -1500,50 +1501,50 @@ } }, "node_modules/@aws-sdk/client-iam": { - "version": "3.1018.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1018.0.tgz", - "integrity": "sha512-waygi8Y4fpSNfzFUOj2MsIDwI6dOXMWWYTJCxYWJpMeB5FnMIxAquRwKvKfk/7qs1NcFPo/df9DlovuxAdrFWA==", + "version": "3.1025.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1025.0.tgz", + "integrity": "sha512-eq/e2jhGci2HVW75NiofCoCPHSes2EsTIZS6Prpx38hOgxIAOQgrZ/DtdaffNUfzMUbXstPo3fxh1RRBQzQiSA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/credential-provider-node": "^3.972.26", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.26", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.12", + "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.12", + "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-retry": "^4.4.44", - "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.0", + "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@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.43", - "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.13", + "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -3166,21 +3167,21 @@ } }, "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==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "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==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, @@ -3189,9 +3190,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, @@ -4032,22 +4033,16 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "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": { - "@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" + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" } }, "node_modules/@nodelib/fs.scandir": { @@ -4326,6 +4321,23 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", @@ -4615,18 +4627,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.13", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", - "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", + "version": "3.23.14", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", + "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.21", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-stream": "^4.5.22", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -4722,14 +4734,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", - "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", + "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -4835,18 +4847,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.28", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", - "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", + "version": "4.4.29", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", + "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/middleware-serde": "^4.2.16", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-middleware": "^4.2.12", + "@smithy/core": "^3.23.14", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-middleware": "^4.2.13", "tslib": "^2.6.2" }, "engines": { @@ -4854,18 +4866,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.46", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", - "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/service-error-classification": "^4.2.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.13", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.0.tgz", + "integrity": "sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/service-error-classification": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.0", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -4874,14 +4887,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.16", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", - "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", + "version": "4.2.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", + "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/core": "^3.23.14", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -4889,12 +4902,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", - "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", + "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -4902,14 +4915,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", - "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", + "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -4917,14 +4930,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", - "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", + "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -4932,12 +4945,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", - "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", + "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -4945,12 +4958,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", - "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", + "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -4958,12 +4971,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", - "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", + "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -4972,12 +4985,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", - "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", + "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -4985,24 +4998,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", - "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", + "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1" + "@smithy/types": "^4.14.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", - "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", + "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -5029,17 +5042,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.8", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", - "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", + "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.21", + "@smithy/core": "^3.23.14", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-stream": "^4.5.22", "tslib": "^2.6.2" }, "engines": { @@ -5047,9 +5060,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", + "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5059,13 +5072,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", - "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", + "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/querystring-parser": "^4.2.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -5195,12 +5208,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", - "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", + "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -5208,13 +5221,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz", - "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.0.tgz", + "integrity": "sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/service-error-classification": "^4.2.13", + "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { @@ -5222,14 +5235,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.21", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", - "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", + "version": "4.5.22", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", + "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.1", - "@smithy/types": "^4.13.1", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -5966,19 +5979,6 @@ "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", @@ -6594,9 +6594,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.247.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.247.0.tgz", - "integrity": "sha512-jwGmLg3qycFx0G+uEhoqk6pzSg6BAiaCQpuUreHUE4BnrhcUEG202BZ+PL8oU943fDjlI/xuwaS+Icru3fecYQ==", + "version": "2.244.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.244.0.tgz", + "integrity": "sha512-j5FVeZv5W+v6j6OnW8RjoN04T+8pYvDJJV7yXhhj4IiGDKPgMH3fflQLQXJousd2QQk+nSAjghDVJcrZ4GFyGA==", "bundleDependencies": [ "@balena/dockerignore", "@aws-cdk/cloud-assembly-api", @@ -6614,10 +6614,10 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.273", + "@aws-cdk/asset-awscli-v1": "2.2.263", "@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.1.1", + "@aws-cdk/cloud-assembly-schema": "^52.1.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.3", @@ -6628,7 +6628,7 @@ "punycode": "^2.3.1", "semver": "^7.7.4", "table": "^6.9.0", - "yaml": "1.10.3" + "yaml": "1.10.2" }, "engines": { "node": ">= 20.0.0" @@ -6638,7 +6638,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.0", + "version": "2.1.1", "bundleDependencies": [ "jsonschema", "semver" @@ -6648,13 +6648,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-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { @@ -6667,7 +6667,47 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { - "version": "7.7.4", + "version": "7.7.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "52.2.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-52.2.0.tgz", + "integrity": "sha512-ourZjixQ/UfsZc7gdk3vt1eHBODMUjQTYYYCY3ZX8fiXyHtWNDAYZPrXUK96jpCC2fLP+tfHTJrBjZ563pmcEw==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.3" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.7.3", "dev": true, "inBundle": true, "license": "ISC", @@ -6743,7 +6783,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.5", + "version": "5.0.3", "dev": true, "inBundle": true, "license": "MIT", @@ -6902,12 +6942,12 @@ } }, "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "10.2.5", + "version": "10.2.4", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.2" }, "engines": { "node": "18 || 20 || >=22" @@ -7015,7 +7055,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.3", + "version": "1.10.2", "dev": true, "inBundle": true, "license": "ISC", @@ -11082,15 +11122,15 @@ } }, "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "dev": true, "license": "MIT" }, @@ -14193,9 +14233,9 @@ } }, "node_modules/vite": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", - "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14220,7 +14260,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/package.json b/package.json index 4955c1359..40dabdcaa 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@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-iam": "^3.1025.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/client-sts": "^3.893.0", 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..836d0e1b6 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts @@ -0,0 +1,343 @@ +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 /abtests 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('/abtests'), + 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 /abtests/{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('/abtests/abt-123'), + expect.objectContaining({ method: 'GET' }) + ); + }); + }); + + describe('updateABTest', () => { + it('sends PUT to /abtests/{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('/abtests/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 /abtests/{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('/abtests/abt-123'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('returns error on failure instead of throwing', 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(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 /abtests', 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/agentcore-ab-tests.ts b/src/cli/aws/agentcore-ab-tests.ts new file mode 100644 index 000000000..6bef70e3f --- /dev/null +++ b/src/cli/aws/agentcore-ab-tests.ts @@ -0,0 +1,358 @@ +/** + * 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 { 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; + }; + }; +} + +export interface ABTestEvaluationConfig { + onlineEvaluationConfigArn: 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; + 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 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}.amazonaws.com`; +} + +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}.amazonaws.com`; +} + +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(); +} + +/** Control plane request — kept for future use. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function cpRequest(options: { region: string; method: string; path: string; body?: string }): Promise { + return signedRequestToEndpoint(getControlPlaneEndpoint(options.region), options); +} + +/** 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.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: '/abtests', + 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: `/abtests/${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: `/abtests/${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: `/abtests/${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: `/abtests${query ? `?${query}` : ''}`, + }); + + const result = data as ListABTestsResult; + return { + abTests: result.abTests ?? [], + nextToken: result.nextToken, + }; +} diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 0a17385c4..74da1ac0a 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,3 +1,4 @@ +import { registerABTestCommand } from './commands/abtest'; import { registerAdd } from './commands/add'; import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; @@ -162,6 +163,9 @@ export function registerCommands(program: Command) { for (const primitive of ALL_PRIMITIVES) { primitive.registerCommands(addCmd, removeCmd); } + + // Register AB test detail command + registerABTestCommand(program); } export const main = async (argv: string[]) => { diff --git a/src/cli/commands/abtest/command.ts b/src/cli/commands/abtest/command.ts new file mode 100644 index 000000000..f591e0a98 --- /dev/null +++ b/src/cli/commands/abtest/command.ts @@ -0,0 +1,156 @@ +/** + * 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 { getErrorMessage } from '../../errors'; +import type { Command } from '@commander-js/extra-typings'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function getRegion(cliRegion?: string): string { + if (cliRegion) return cliRegion; + return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; +} + +async function resolveABTestId(testName: string, region: string): Promise<{ abTestId: string; error?: string }> { + try { + const configIO = new ConfigIO(); + const deployedState = await configIO.readDeployedState(); + + for (const target of Object.values(deployedState.targets ?? {})) { + const abTests = target.resources?.abTests; + if (abTests?.[testName]) { + return { abTestId: abTests[testName].abTestId }; + } + } + } catch { + // No deployed state available + } + + try { + const result = await listABTests({ region, maxResults: 100 }); + const match = result.abTests.find(t => t.name === testName); + if (match) { + return { abTestId: match.abTestId }; + } + } catch { + // API call failed + } + + return { abTestId: '', error: `AB test "${testName}" not found in deployed state or API.` }; +} + +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(` Gateway: ${test.gatewayArn}`); + if (test.description) lines.push(` Description: ${test.description}`); + + for (const variant of test.variants) { + const bundleRef = variant.variantConfiguration.configurationBundle; + lines.push( + ` Variant ${variant.name}: weight=${variant.weight}, bundle=${bundleRef.bundleArn}, version=${bundleRef.bundleVersion}` + ); + } + + 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('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 = 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/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 22e97cb78..32a37489a 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -31,6 +31,7 @@ import { validateProject, } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; +import { setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; import { setupConfigBundles } from '../../operations/deploy/post-deploy-config-bundles'; import type { DeployResult } from './types'; @@ -471,6 +472,35 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 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('; '); + throw new Error(`AB test setup failed: ${errorMessages}`); + } + } + // Post-deploy: Enable CloudWatch Transaction Search (non-blocking, silent) const nextSteps = agentNames.length > 0 ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS]; const notes: string[] = []; diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index efe717b0b..ca587fd84 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -61,6 +61,7 @@ describe('resolveAgentContext', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }, deployedState: { targets: { @@ -123,6 +124,7 @@ describe('resolveAgentContext', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }, }); const result = resolveAgentContext(context, {}); @@ -165,6 +167,7 @@ describe('resolveAgentContext', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }, deployedState: { targets: { @@ -217,6 +220,7 @@ describe('resolveAgentContext', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index 5a3183ea9..cbc0c94e1 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -1,3 +1,5 @@ +import { ConfigIO } from '../../../lib'; +import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; import { getErrorMessage } from '../../errors'; import { handlePauseResume } from '../../operations/eval'; import type { OnlineEvalActionOptions } from '../../operations/eval'; @@ -67,12 +69,135 @@ function registerOnlineEvalSubcommand(parent: Command, action: 'pause' | 'resume }); } +function getRegion(cliRegion?: string): string { + if (cliRegion) return cliRegion; + return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; +} + +async function resolveABTestId(testName: string, region: string): Promise<{ abTestId: string; error?: string }> { + try { + const configIO = new ConfigIO(); + const deployedState = await configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets ?? {})) { + const abTests = target.resources?.abTests; + if (abTests?.[testName]) { + return { abTestId: abTests[testName].abTestId }; + } + } + } catch { + // No deployed state + } + + try { + const result = await listABTests({ region, maxResults: 100 }); + const match = result.abTests.find(t => t.name === testName); + if (match) return { abTestId: match.abTestId }; + } catch { + // API call failed + } + + return { abTestId: '', 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(`${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 = 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('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 = 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); + } + }); }; diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index a9b5acfec..ae8815e83 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -35,6 +35,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise item.description, }); + const abTests = diffResourceSet({ + resourceType: 'ab-test', + localItems: project.abTests ?? [], + deployedRecord: resources?.abTests ?? {}, + getIdentifier: deployed => deployed.abTestArn, + getLocalDetail: item => item.description, + }); + return [ ...agents, ...credentials, @@ -221,6 +230,7 @@ export function computeResourceStatuses( ...policyEngines, ...policies, ...configBundles, + ...abTests, ]; } diff --git a/src/cli/commands/stop/index.ts b/src/cli/commands/stop/index.ts index 3f55d16c9..1f1a5e1e2 100644 --- a/src/cli/commands/stop/index.ts +++ b/src/cli/commands/stop/index.ts @@ -1 +1 @@ -export { registerStop } from './command'; +export { registerStop } from '../pause/command'; diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts index 2ccd000d5..e42541156 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -54,6 +54,7 @@ describe('requiresUv', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(requiresUv(project)).toBe(true); }); @@ -80,6 +81,7 @@ describe('requiresUv', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(requiresUv(project)).toBe(false); }); @@ -97,6 +99,7 @@ describe('requiresUv', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(requiresUv(project)).toBe(false); }); @@ -125,6 +128,7 @@ describe('requiresContainerRuntime', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -151,6 +155,7 @@ describe('requiresContainerRuntime', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -168,6 +173,7 @@ describe('requiresContainerRuntime', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -202,6 +208,7 @@ describe('requiresContainerRuntime', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -270,6 +277,7 @@ describe('checkDependencyVersions', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const result = await checkDependencyVersions(project); @@ -291,6 +299,7 @@ describe('checkDependencyVersions', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const result = await checkDependencyVersions(project); @@ -320,6 +329,7 @@ describe('checkDependencyVersions', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 87a21a4e5..de85071b6 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -17,7 +17,8 @@ export interface RemoveLoggerOptions { | 'online-eval' | 'policy-engine' | 'policy' - | 'config-bundle'; + | 'config-bundle' + | 'ab-test'; /** Name of the resource being removed */ resourceName: string; } 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 279d9189a..8c0d30944 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -73,6 +73,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; await configIO.writeProjectSpec(project); 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..9089fb161 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts @@ -0,0 +1,437 @@ +import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema'; +import { setupABTests } from '../post-deploy-ab-tests.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mocks ────────────────────────────────────────────────────────── + +const { mockCreateABTest, mockDeleteABTest, mockListABTests, mockGetCredentialProvider, mockIAMSend } = vi.hoisted( + () => ({ + mockCreateABTest: vi.fn(), + mockDeleteABTest: vi.fn(), + mockListABTests: vi.fn(), + mockGetCredentialProvider: vi.fn().mockReturnValue(undefined), + mockIAMSend: vi.fn(), + }) +); + +vi.mock('../../../aws/agentcore-ab-tests', () => ({ + createABTest: mockCreateABTest, + deleteABTest: mockDeleteABTest, + 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: [], + abTests, + }; +} + +const sampleABTest = { + name: 'TestOne', + gatewayArn: '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: [] }); + }); + + 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('skips already-deployed test', async () => { + 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('skipped'); + expect(mockCreateABTest).not.toHaveBeenCalled(); + }); + + it('skips test found via API list (state loss recovery)', async () => { + mockListABTests.mockResolvedValue({ + abTests: [{ name: 'TestOne', abTestId: 'abt-api', abTestArn: 'arn:abt:api' }], + }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([sampleABTest]), + }); + + expect(result.results[0]!.status).toBe('skipped'); + expect(result.abTests.TestOne!.abTestId).toBe('abt-api'); + expect(mockCreateABTest).not.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, + gatewayArn: '{{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 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('deletes orphaned AB test not in project spec', async () => { + mockDeleteABTest.mockResolvedValue({ success: true }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + RemovedTest: { abTestId: 'abt-old', abTestArn: 'arn:abt:old' }, + }, + }); + + expect(mockDeleteABTest).toHaveBeenCalledWith({ region: 'us-east-1', abTestId: 'abt-old' }); + expect(result.results[0]!.status).toBe('deleted'); + }); + + it('cleans up auto-created IAM role on deletion', async () => { + mockDeleteABTest.mockResolvedValue({ success: true }); + mockIAMSend.mockResolvedValue({}); + + await setupABTests({ + 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 setupABTests({ + 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 setupABTests({ + 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'); + }); + }); + + 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'); + + // 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('GatewayRuleStatement'); + expect(sids).toContain('GatewayReadStatement'); + expect(sids).toContain('GatewayListStatement'); + expect(sids).toContain('OnlineEvaluationConfigStatement'); + expect(sids).toContain('ConfigurationBundleReadStatement'); + expect(sids).toContain('CloudWatchLogReadStatement'); + expect(sids).toContain('CloudWatchIndexPolicyStatement'); + + // ListGateways must use wildcard resource (can't be scoped) + const listGatewayStmt = policy.Statement.find((s: { Sid: string }) => s.Sid === 'GatewayListStatement'); + expect(listGatewayStmt.Resource).toEqual(['*']); + }); + }); + + 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 setupABTests({ + 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, skips existing, and deletes orphaned in one call', async () => { + const newTest = { ...sampleABTest, name: 'NewTest' }; + const keptTest = { ...sampleABTest, name: 'KeptTest' }; + + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-new', abTestArn: 'arn:abt:new' }); + mockDeleteABTest.mockResolvedValue({ success: true }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([newTest, keptTest]), + existingABTests: { + KeptTest: { abTestId: 'abt-kept', abTestArn: 'arn:abt:kept' }, + OrphanTest: { abTestId: 'abt-orphan', abTestArn: 'arn:abt:orphan' }, + }, + }); + + expect(result.results).toHaveLength(3); + const statuses = result.results.map(r => `${r.testName}:${r.status}`); + expect(statuses).toContain('NewTest:created'); + expect(statuses).toContain('KeptTest:skipped'); + expect(statuses).toContain('OrphanTest:deleted'); + }); + }); +}); 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..e3733b929 --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-ab-tests.ts @@ -0,0 +1,468 @@ +import type { ABTestDeployedState, AgentCoreProjectSpec, DeployedResourceState } from '../../../schema'; +import { getCredentialProvider } from '../../aws/account'; +import { createABTest, deleteABTest, listABTests } from '../../aws/agentcore-ab-tests'; +import type { ABTestEvaluationConfig, ABTestVariant, TrafficAllocationConfig } from '../../aws/agentcore-ab-tests'; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + IAMClient, + PutRolePolicyCommand, +} from '@aws-sdk/client-iam'; +import { randomBytes } 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; +} + +export interface SetupABTestsResult { + results: ABTestSetupResult[]; + abTests: Record; + hasErrors: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const AB_TEST_ROLE_POLICY_NAME = 'ABTestExecutionPolicy'; + +// ============================================================================ +// 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 = {}; + + const specTestNames = new Set(projectSpec.abTests.map(t => t.name)); + + // Create or skip tests from the spec + for (const testSpec of projectSpec.abTests) { + try { + const existingTest = existingABTests?.[testSpec.name]; + + if (existingTest) { + // Already deployed — skip (AB tests are updated via lifecycle commands, not deploy) + abTests[testSpec.name] = existingTest; + results.push({ + testName: testSpec.name, + status: 'skipped', + abTestId: existingTest.abTestId, + abTestArn: existingTest.abTestArn, + }); + continue; + } + + // Try to find by name via list (handles re-creation after state loss) + const existingByName = await findABTestByName(region, testSpec.name); + if (existingByName) { + abTests[testSpec.name] = { + abTestId: existingByName.abTestId, + abTestArn: existingByName.abTestArn, + }; + results.push({ + testName: testSpec.name, + status: 'skipped', + abTestId: existingByName.abTestId, + abTestArn: existingByName.abTestArn, + }); + continue; + } + + // Resolve ARN references from deployed state + const resolvedVariants = resolveVariants(testSpec.variants, deployedResources); + const resolvedGatewayArn = resolveGatewayArn(testSpec.gatewayArn, deployedResources); + const resolvedEvalConfig = resolveEvalConfig(testSpec.evaluationConfig, deployedResources); + + // Resolve or auto-create role + let resolvedRoleArn: string; + let roleCreatedByCli = false; + if (testSpec.roleArn) { + resolvedRoleArn = testSpec.roleArn; + } else { + resolvedRoleArn = await getOrCreateABTestRole({ + region, + projectName: projectSpec.name, + testName: testSpec.name, + gatewayArn: resolvedGatewayArn, + onlineEvalConfigArn: resolvedEvalConfig.onlineEvaluationConfigArn, + }); + roleCreatedByCli = true; + } + + const result = await createABTest({ + region, + name: testSpec.name, + description: testSpec.description, + gatewayArn: resolvedGatewayArn, + roleArn: resolvedRoleArn, + variants: resolvedVariants, + evaluationConfig: resolvedEvalConfig, + trafficAllocationConfig: testSpec.trafficAllocationConfig as TrafficAllocationConfig | undefined, + maxDurationDays: testSpec.maxDurationDays, + enableOnCreate: testSpec.enableOnCreate, + }); + + abTests[testSpec.name] = { + abTestId: result.abTestId, + abTestArn: result.abTestArn, + roleArn: resolvedRoleArn, + roleCreatedByCli, + }; + + results.push({ + testName: testSpec.name, + status: 'created', + abTestId: result.abTestId, + abTestArn: result.abTestArn, + }); + } catch (err) { + results.push({ + testName: testSpec.name, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Delete orphaned AB tests (in deployed-state but removed from spec) + if (existingABTests) { + for (const [testName, testState] of Object.entries(existingABTests)) { + if (!specTestNames.has(testName)) { + try { + const deleteResult = await deleteABTest({ + region, + abTestId: testState.abTestId, + }); + + // Clean up the auto-created IAM role if we created it + if (testState.roleCreatedByCli && testState.roleArn) { + await deleteABTestRole(region, testState.roleArn); + } + + results.push({ + testName, + status: deleteResult.success ? 'deleted' : 'error', + error: deleteResult.error, + }); + } catch (err) { + results.push({ + testName, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + } + + return { + results, + abTests, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +// ============================================================================ +// ARN Resolution Helpers +// ============================================================================ + +async function findABTestByName( + region: string, + name: string +): Promise<{ abTestId: string; abTestArn: string } | undefined> { + try { + const result = await listABTests({ region, maxResults: 100 }); + return result.abTests.find(t => t.name.toLowerCase() === name.toLowerCase()); + } catch { + return undefined; + } +} + +/** + * Resolve variant config bundle references. + * If bundleArn is a name (not an ARN), look it up in deployed config bundles. + */ +function resolveVariants( + variants: { + name: 'C' | 'T1'; + weight: number; + variantConfiguration: { configurationBundle: { bundleArn: string; bundleVersion: string } }; + }[], + deployedResources?: DeployedResourceState +): ABTestVariant[] { + return variants.map(v => ({ + name: v.name, + weight: v.weight, + variantConfiguration: { + configurationBundle: { + bundleArn: resolveConfigBundleArn(v.variantConfiguration.configurationBundle.bundleArn, deployedResources), + bundleVersion: resolveConfigBundleVersion( + v.variantConfiguration.configurationBundle.bundleArn, + v.variantConfiguration.configurationBundle.bundleVersion, + deployedResources + ), + }, + }, + })); +} + +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; +} + +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; + } + + return ref; +} + +function resolveEvalConfig( + config: { onlineEvaluationConfigArn: string }, + deployedResources?: DeployedResourceState +): ABTestEvaluationConfig { + const ref = config.onlineEvaluationConfigArn; + + if (ref.startsWith('arn:')) return { onlineEvaluationConfigArn: ref }; + + // Try to resolve from deployed online eval configs + const configs = deployedResources?.onlineEvalConfigs; + if (configs?.[ref]) { + return { onlineEvaluationConfigArn: configs[ref].onlineEvaluationConfigArn }; + } + + return { onlineEvaluationConfigArn: 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 { + const hash = randomBytes(6).toString('base64url').slice(0, 8); + const base = `AgentCore-${projectName}-ABTest${testName}`; + // IAM role names max 64 chars + const truncated = base.slice(0, 55); + return `${truncated}-${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; + onlineEvalConfigArn: string; +} + +async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise { + const { region, projectName, testName, gatewayArn, onlineEvalConfigArn } = 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] ?? '*'; + // Extract gateway ID for resource scoping + const gatewayId = gatewayArn.split('/').pop() ?? '*'; + + 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', + }, + ], + }); + + 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 }, + ], + }) + ); + + const policy = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'GatewayRuleStatement', + Effect: 'Allow', + Action: [ + 'bedrock-agentcore:CreateGatewayRule', + 'bedrock-agentcore:UpdateGatewayRule', + 'bedrock-agentcore:GetGatewayRule', + 'bedrock-agentcore:DeleteGatewayRule', + ], + Resource: [`arn:aws:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayId}`], + }, + { + Sid: 'GatewayReadStatement', + Effect: 'Allow', + Action: ['bedrock-agentcore:GetGateway'], + Resource: [`arn:aws:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayId}`], + }, + { + Sid: 'GatewayListStatement', + Effect: 'Allow', + Action: ['bedrock-agentcore:ListGateways'], + Resource: ['*'], + }, + { + Sid: 'OnlineEvaluationConfigStatement', + Effect: 'Allow', + Action: ['bedrock-agentcore:GetOnlineEvaluationConfig', 'bedrock-agentcore:UpdateOnlineEvaluationConfig'], + Resource: [onlineEvalConfigArn], + }, + { + Sid: 'ConfigurationBundleReadStatement', + Effect: 'Allow', + Action: ['bedrock-agentcore:GetConfigurationBundle', 'bedrock-agentcore:GetConfigurationBundleVersion'], + Resource: [`arn:aws:bedrock-agentcore:${region}:${accountId}:configuration-bundle/*`], + }, + { + Sid: 'CloudWatchLogReadStatement', + Effect: 'Allow', + Action: [ + 'logs:DescribeLogGroups', + 'logs:StartQuery', + 'logs:GetQueryResults', + 'logs:StopQuery', + 'logs:FilterLogEvents', + 'logs:GetLogEvents', + ], + Resource: [ + `arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*`, + `arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*:*`, + `arn:aws:logs:${region}:${accountId}:log-group:aws/spans`, + `arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`, + ], + }, + { + Sid: 'CloudWatchIndexPolicyStatement', + Effect: 'Allow', + Action: ['logs:DescribeIndexPolicies', 'logs:PutIndexPolicy'], + Resource: [ + `arn:aws:logs:${region}:${accountId}:log-group:aws/spans`, + `arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`, + ], + }, + ], + }); + + await iamClient.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: AB_TEST_ROLE_POLICY_NAME, + PolicyDocument: policy, + }) + ); + + // Wait for IAM role propagation before returning + await new Promise(resolve => setTimeout(resolve, 15_000)); + + return createResult.Role!.Arn!; +} + +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/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 64aeb7256..22423266f 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -22,6 +22,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project); @@ -50,6 +51,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project); @@ -78,6 +80,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -112,6 +115,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -141,6 +145,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -168,6 +173,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -198,6 +204,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; // No configRoot provided @@ -228,6 +235,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -258,6 +266,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -287,6 +296,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -316,6 +326,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -345,6 +356,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -374,6 +386,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -404,6 +417,7 @@ describe('getDevConfig', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -447,6 +461,7 @@ describe('getAgentPort', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -466,6 +481,7 @@ describe('getAgentPort', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -490,6 +506,7 @@ describe('getDevSupportedAgents', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -517,6 +534,7 @@ describe('getDevSupportedAgents', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -552,6 +570,7 @@ describe('getDevSupportedAgents', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const supported = getDevSupportedAgents(project); @@ -581,6 +600,7 @@ describe('getDevSupportedAgents', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const supported = getDevSupportedAgents(project); @@ -618,6 +638,7 @@ describe('getDevSupportedAgents', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts new file mode 100644 index 000000000..afda7e92a --- /dev/null +++ b/src/cli/primitives/ABTestPrimitive.ts @@ -0,0 +1,284 @@ +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 { BasePrimitive } from './BasePrimitive'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +export interface AddABTestOptions { + name: string; + description?: string; + gatewayArn: string; + roleArn?: string; + controlBundle: string; + controlVersion: string; + treatmentBundle: string; + treatmentVersion: string; + controlWeight: number; + treatmentWeight: number; + onlineEval: string; + trafficHeaderName?: string; + maxDurationDays?: number; + 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): 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.` }; + } + + project.abTests.splice(index, 1); + 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 afterSpec = { + ...project, + abTests: project.abTests.filter(t => t.name !== testName), + }; + + 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 { + addCmd + .command('ab-test') + .description('Add an A/B test to the project') + .option('--name ', 'AB test name') + .option('--description ', 'AB test description') + .option('--gateway-arn ', 'Gateway ARN') + .option('--role-arn ', 'IAM role ARN for the AB test (auto-created if not provided)') + .option('--control-bundle ', 'Control variant config bundle name or ARN') + .option('--control-version ', 'Control variant config bundle version') + .option('--treatment-bundle ', 'Treatment variant config bundle name or ARN') + .option('--treatment-version ', 'Treatment variant config bundle version') + .option('--control-weight ', 'Traffic weight for control (1-100)', parseInt) + .option('--treatment-weight ', 'Traffic weight for treatment (1-100)', parseInt) + .option('--online-eval ', 'Online evaluation config name or ARN') + .option('--traffic-header ', 'Header name for traffic routing') + .option('--max-duration ', 'Maximum duration in days (1-90)', parseInt) + .option('--enable', 'Enable the AB test on creation') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + name?: string; + description?: string; + gatewayArn?: string; + roleArn?: string; + controlBundle?: string; + controlVersion?: string; + treatmentBundle?: string; + treatmentVersion?: string; + controlWeight?: number; + treatmentWeight?: number; + onlineEval?: 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); + } + + 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'); + if (!cliOptions.gatewayArn) fail('--gateway-arn is required'); + 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, + gatewayArn: cliOptions.gatewayArn!, + 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, + 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 createABTest(options: AddABTestOptions): Promise { + const project = await this.readProjectSpec(); + + this.checkDuplicate(project.abTests, options.name); + + const abTest: ABTest = { + name: options.name, + ...(options.description && { description: options.description }), + gatewayArn: options.gatewayArn, + ...(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.push(abTest); + await this.writeProjectSpec(project); + + return abTest; + } +} diff --git a/src/cli/primitives/__tests__/ABTestPrimitive.test.ts b/src/cli/primitives/__tests__/ABTestPrimitive.test.ts new file mode 100644 index 000000000..103a1d721 --- /dev/null +++ b/src/cli/primitives/__tests__/ABTestPrimitive.test.ts @@ -0,0 +1,244 @@ +import { ABTestPrimitive } from '../ABTestPrimitive.js'; +import type { AddABTestOptions } from '../ABTestPrimitive.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, + findConfigRoot: () => '/fake/root', +})); + +function makeProject(abTests: { name: string }[] = []) { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests, + }; +} + +const validOptions: AddABTestOptions = { + name: 'MyTest', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-abc', + 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'); + } + }); + }); + + 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 b4b04b230..edac406d0 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -14,6 +14,7 @@ const defaultProject: AgentCoreProjectSpec = { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; 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 7638f62ea..171ad6b6f 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -94,6 +94,7 @@ describe('createManagedOAuthCredential', () => { agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 09811f000..a7e6d1376 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'; @@ -16,6 +17,7 @@ export { gatewayPrimitive, gatewayTargetPrimitive, configBundlePrimitive, + abTestPrimitive, getPrimitive, } from './registry'; export { SOURCE_CODE_NOTE } from './constants'; diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index 0967de86c..ea411597d 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -1,3 +1,4 @@ +import { ABTestPrimitive } from './ABTestPrimitive'; import { AgentPrimitive } from './AgentPrimitive'; import type { BasePrimitive } from './BasePrimitive'; import { ConfigBundlePrimitive } from './ConfigBundlePrimitive'; @@ -24,6 +25,7 @@ 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(); /** * All primitives in display order. @@ -39,6 +41,7 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ policyEnginePrimitive, policyPrimitive, configBundlePrimitive, + abTestPrimitive, ]; /** diff --git a/src/cli/project.ts b/src/cli/project.ts index bbc5d3b17..13b02c798 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -19,6 +19,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS agentCoreGateways: [], policyEngines: [], configBundles: [], + abTests: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 932cbc4eb..492952f78 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -3,6 +3,7 @@ import { createProgram } from '../cli'; import { LayoutProvider } from './context'; import { CLI_ONLY_EXAMPLES } from './copy'; 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'; @@ -54,6 +55,7 @@ type Route = | { 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 @@ -133,6 +135,8 @@ function AppContent() { setRoute({ name: 'update' }); } else if (id === 'config-bundle') { setRoute({ name: 'config-bundle' }); + } else if (id === 'ab-test') { + setRoute({ name: 'ab-test' }); } }; @@ -316,6 +320,10 @@ function AppContent() { 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/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 22e9774e4..8cc83fa6b 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -21,6 +21,7 @@ const ICONS = { 'policy-engine': '▣', policy: '▢', 'config-bundle': '⬡', + 'ab-test': '⚗', } as const; interface ResourceGraphProps { @@ -130,6 +131,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res 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(() => { @@ -311,6 +313,27 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res )} + {/* 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 && ( @@ -442,7 +465,8 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res {ICONS['online-eval']} online-eval{' '} {ICONS.gateway} gateway{' '} {ICONS['policy-engine']} policy engine{' '} - {ICONS['config-bundle']} config bundle + {ICONS['config-bundle']} config bundle{' '} + {ICONS['ab-test']} ab test {resourceStatuses && resourceStatuses.length > 0 && ( diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 4e6eb774b..3465330c5 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -43,12 +43,12 @@ export const COMMAND_DESCRIPTIONS = { traces: 'View and download agent traces.', 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.', + 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 history from past runs.', run: 'Run evaluations, batch evaluations, or optimization recommendations.', - stop: 'Stop a running batch evaluation.', + stop: 'Stop a running batch evaluation or A/B test.', import: 'Import a runtime, memory, or starter toolkit into this project. [experimental]', update: 'Check for and install CLI updates', validate: 'Validate agentcore/ config files.', @@ -120,10 +120,11 @@ export const CLI_ONLY_EXAMPLES: Record', 'agentcore stop batch-evaluation -i --json', + 'agentcore stop ab-test ', ], }, evals: { diff --git a/src/cli/tui/hooks/useCreateABTest.ts b/src/cli/tui/hooks/useCreateABTest.ts new file mode 100644 index 000000000..2415bee36 --- /dev/null +++ b/src/cli/tui/hooks/useCreateABTest.ts @@ -0,0 +1,73 @@ +import { abTestPrimitive } from '../../primitives/registry'; +import { useCallback, useEffect, useState } from 'react'; + +interface CreateABTestConfig { + name: string; + description?: string; + gateway: string; + 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, + gatewayArn: config.gateway, + 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 reset = useCallback(() => { + setStatus({ state: 'idle' }); + }, []); + + return { status, createABTest: create, 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/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index 56e1391b9..ba95f3dc3 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -5,6 +5,7 @@ import type { RemovableCredential } from '../../primitives/CredentialPrimitive'; import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; import { + abTestPrimitive, agentPrimitive, configBundlePrimitive, credentialPrimitive, @@ -153,6 +154,19 @@ 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 + ); +} + // ============================================================================ // Preview Hook // ============================================================================ @@ -229,6 +243,11 @@ export function useRemovalPreview() { [loadPreview] ); + const loadABTestPreview = useCallback( + (name: string) => loadPreview(n => abTestPrimitive.previewRemove(n), name), + [loadPreview] + ); + const reset = useCallback(() => { setState({ isLoading: false, preview: null, error: null }); }, []); @@ -245,6 +264,7 @@ export function useRemovalPreview() { loadPolicyEnginePreview, loadPolicyPreview, loadConfigBundlePreview, + loadABTestPreview, reset, }; } 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..5f38d28ba --- /dev/null +++ b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx @@ -0,0 +1,247 @@ +import { getABTest, updateABTest } from '../../../aws/agentcore-ab-tests'; +import type { GetABTestResult } from '../../../aws/agentcore-ab-tests'; +import { getErrorMessage } from '../../../errors'; +import { Screen } from '../../components'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +interface ABTestDetailScreenProps { + abTestId: string; + region: string; + onExit: () => void; +} + +/** 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); +} + +/** Pad a string to a fixed width. */ +function pad(str: string, width: number): string { + return str.length >= width ? str : str + ' '.repeat(width - str.length); +} + +/** 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}`; +} + +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 hasFetched = useRef(false); + useEffect(() => { + if (hasFetched.current) return; + hasFetched.current = true; + const load = async () => { + try { + const result = await getABTest({ region, abTestId }); + setTest(result); + } 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 (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 (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 helpKeys = 'P pause · R resume · S stop · Esc exit'; + + // Build status text: only show provisioning status if not ACTIVE + const statusPrefix = test.status !== 'ACTIVE' ? `${test.status} ` : ''; + + // Duration text + const durationText = test.maxDurationDays ? `${test.maxDurationDays} day max` : ''; + + // Column width for side-by-side variants + const colW = 28; + + return ( + + + {/* ── Header: Line 1 — status ─────────────────────────── */} + + + {statusPrefix && {statusPrefix}} + {`● ${test.executionStatus}`} + + {durationText && {durationText}} + + + {/* ── Header: Line 2 — gateway ────────────────────────── */} + + {`Gateway: ${extractId(test.gatewayArn)}`} + + + {/* ── Description (if present) ────────────────────────── */} + {test.description && ( + + {`Description: ${test.description}`} + + )} + + {/* ── Variants: side-by-side ──────────────────────────── */} + + + {'CONTROL (C)'} + {`${String(controlVariant?.weight ?? 'N/A')}% traffic`} + {`${extractId(controlVariant?.variantConfiguration.configurationBundle.bundleArn ?? '')} @ ${shortVersion(controlVariant?.variantConfiguration.configurationBundle.bundleVersion ?? '')}`} + + + {'TREATMENT (T1)'} + {`${String(treatmentVariant?.weight ?? 'N/A')}% traffic`} + {`${extractId(treatmentVariant?.variantConfiguration.configurationBundle.bundleArn ?? '')} @ ${shortVersion(treatmentVariant?.variantConfiguration.configurationBundle.bundleVersion ?? '')}`} + + + + {/* ── Evaluation Results ───────────────────────────────── */} + + {test.results ? ( + <> + {rule('Results', test.results.analysisTimestamp)} + + {`${pad('', 15)}${pad('Control', 12)}${pad('Treatment', 12)}Δ`} + + {test.results.evaluatorMetrics.map((metric, i) => ( + 0 ? 1 : 0}> + + {pad(extractId(metric.evaluatorArn), 15)} + {pad(metric.controlStats.mean.toFixed(4), 12)} + {pad(metric.variantResults[0]?.mean.toFixed(4) ?? '', 12)} + {metric.variantResults[0]?.isSignificant ? ( + {`+${(metric.variantResults[0]?.percentChange ?? 0).toFixed(2)}% ✓`} + ) : ( + {`${(metric.variantResults[0]?.percentChange ?? 0).toFixed(2)}% ✗`} + )} + + + {pad('', 15)} + {pad(`n=${metric.controlStats.sampleSize}`, 12)} + {pad(`n=${metric.variantResults[0]?.sampleSize ?? ''}`, 12)} + {`p=${metric.variantResults[0]?.pValue?.toFixed(3) ?? 'N/A'}`} + + + ))} + + ) : ( + <> + {rule('Results')} + + No evaluation results yet. + + + )} + + + {/* ── Stop confirmation ────────────────────────────────── */} + {confirmingStop && ( + + + {'Stop this AB test permanently? This cannot be undone. (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..e9835b1ca --- /dev/null +++ b/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx @@ -0,0 +1,87 @@ +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 = await configIO.readDeployedState(); + 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); + setRegion(process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'); + } 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..619502b82 --- /dev/null +++ b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx @@ -0,0 +1,157 @@ +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 { AddABTestScreen } from './AddABTestScreen'; +import type { AddABTestConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { 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, reset: resetCreate } = useCreateABTest(); + const { names: existingNames } = useExistingABTestNames(); + const [flow, setFlow] = useState({ name: 'create-wizard' }); + + // Load deployed state for bundle lists + const [deployedBundles, setDeployedBundles] = useState<{ name: string; bundleId: string }[]>([]); + const [onlineEvalConfigs, setOnlineEvalConfigs] = useState([]); + const [region, setRegion] = useState('us-east-1'); + + useEffect(() => { + const load = 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; + } + + // Online eval configs from project spec + const evalConfigs = projectSpec.onlineEvalConfigs ?? []; + setOnlineEvalConfigs(evalConfigs.map(c => c.name)); + + // Region from env + setRegion(process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'); + } catch { + // No deployed state — lists will be empty + } + }; + + void load(); + }, []); + + 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) => { + const controlWeight = 100 - config.treatmentWeight; + void createABTest({ + name: config.name, + description: config.description || undefined, + gateway: config.gateway, + 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] + ); + + 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..1f5b2efc4 --- /dev/null +++ b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx @@ -0,0 +1,247 @@ +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 } from './types'; +import { AB_TEST_STEP_LABELS } from './types'; +import { useAddABTestWizard } from './useAddABTestWizard'; +import React, { useMemo } from 'react'; + +interface AddABTestScreenProps { + onComplete: (config: AddABTestConfig) => void; + onExit: () => void; + existingTestNames: string[]; + deployedBundles: { name: string; bundleId: string }[]; + onlineEvalConfigs: string[]; + fetchBundleVersions: (bundleId: string) => Promise<{ versionId: string; createdAt: string }[]>; +} + +export function AddABTestScreen({ + onComplete, + onExit, + existingTestNames, + deployedBundles, + onlineEvalConfigs, + fetchBundleVersions, +}: AddABTestScreenProps) { + const wizard = useAddABTestWizard(); + + // Build select items + 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 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: ${new Date(v.createdAt).toLocaleString()}`, + })); + setControlVersionItems(items); + setTreatmentVersionItems(items); + setControlVersionLoadState('loaded'); + setTreatmentVersionLoadState('loaded'); + }) + .catch(() => { + setControlVersionLoadState('error'); + setTreatmentVersionLoadState('error'); + }); + }, + [deployedBundles, fetchBundleVersions, controlVersionItems.length] + ); + + // Step flags + const isNameStep = wizard.step === 'name'; + const isDescriptionStep = wizard.step === 'description'; + const isGatewayStep = wizard.step === 'gateway'; + const isVariantsStep = wizard.step === 'variants'; + const isOnlineEvalStep = wizard.step === 'onlineEval'; + const isMaxDurationStep = wizard.step === 'maxDuration'; + const isEnableStep = wizard.step === 'enableOnCreate'; + const isConfirmStep = wizard.step === 'confirm'; + + // Navigation hooks for select steps + const onlineEvalNav = useListNavigation({ + items: onlineEvalItems, + onSelect: item => wizard.setOnlineEval(item.id), + onExit: () => wizard.goBack(), + isActive: isOnlineEvalStep, + }); + + 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 = isOnlineEvalStep || isEnableStep; + const helpText = isSelectStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isVariantsStep + ? 'Enter to select · Esc back' + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ; + + const controlWeight = 100 - wizard.config.treatmentWeight; + + return ( + + + {isNameStep && ( + (existingTestNames.includes(value) ? `AB test "${value}" already exists` : true)} + /> + )} + + {isDescriptionStep && ( + wizard.goBack()} + /> + )} + + {isGatewayStep && ( + wizard.goBack()} + customValidation={(value: string) => (value.trim().length > 0 ? true : 'Gateway ARN is required')} + /> + )} + + {isVariantsStep && ( + wizard.goBack()} + /> + )} + + {isOnlineEvalStep && ( + + )} + + {isMaxDurationStep && ( + { + if (!value.trim()) { + wizard.setMaxDuration(undefined); + return; + } + const n = parseInt(value, 10); + if (!isNaN(n) && n >= 1 && n <= 90) wizard.setMaxDuration(n); + }} + onCancel={() => wizard.goBack()} + customValidation={(value: string) => { + if (!value.trim()) return true; + const n = parseInt(value, 10); + if (isNaN(n)) return 'Must be a number'; + if (n < 1 || n > 90) return 'Must be between 1 and 90'; + return true; + }} + /> + )} + + {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/VariantConfigForm.tsx b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx new file mode 100644 index 000000000..ccae5d7d1 --- /dev/null +++ b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx @@ -0,0 +1,248 @@ +import type { SelectableItem } from '../../components'; +import { TextInput, WizardSelect } from '../../components'; +import { useListNavigation } from '../../hooks'; +import { Box, Text } from 'ink'; +import React, { useCallback, 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; +} + +export function VariantConfigForm({ + bundleItems, + fetchVersionItems, + controlVersionItems, + treatmentVersionItems, + controlVersionLoadState, + treatmentVersionLoadState, + onComplete, + onCancel, +}: 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 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: bundleItems, + onSelect: item => { + 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: bundleItems, + onSelect: item => { + 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' ? ( + bundleItems.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' ? ( + + { + 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..5065835e9 --- /dev/null +++ b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx @@ -0,0 +1,212 @@ +import type { VariantConfig } from '../VariantConfigForm'; +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; + setGateway: (gw: string) => void; + setVariants: (vc: VariantConfig) => void; + setOnlineEval: (eval_: string) => void; + setMaxDuration: (days: number | undefined) => void; + setEnableOnCreate: (enable: boolean) => void; + goBack: () => void; + reset: () => void; +} + +const ImperativeHarness = React.forwardRef((_, ref) => { + const wizard = useAddABTestWizard(); + useImperativeHandle(ref, () => ({ + setName: wizard.setName, + setDescription: wizard.setDescription, + setGateway: wizard.setGateway, + setVariants: wizard.setVariants, + setOnlineEval: wizard.setOnlineEval, + setMaxDuration: wizard.setMaxDuration, + setEnableOnCreate: wizard.setEnableOnCreate, + goBack: wizard.goBack, + reset: wizard.reset, + })); + return ( + + step:{wizard.step} + name:{wizard.config.name} + description:{wizard.config.description} + gateway:{wizard.config.gateway} + 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 name', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('step:name'); + }); + + 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 8 steps', () => { + const { lastFrame } = render(); + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain('steps:name,description,gateway,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('A description')); + + expect(lastFrame()).toContain('step:gateway'); + expect(lastFrame()).toContain('description:A description'); + }); + + it('setGateway advances to variants', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setGateway('arn:gateway')); + + expect(lastFrame()).toContain('step:variants'); + expect(lastFrame()).toContain('gateway:arn:gateway'); + }); + + 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('gw')); + 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('gw')); + 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:name'); + }); + }); + + 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:name'); + expect(lastFrame()).toContain('name:'); + expect(lastFrame()).toContain('treatmentWeight:20'); + }); + }); +}); 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..0748c4408 --- /dev/null +++ b/src/cli/tui/screens/ab-test/types.ts @@ -0,0 +1,38 @@ +// ───────────────────────────────────────────────────────────────────────────── +// AB Test Wizard Types +// ───────────────────────────────────────────────────────────────────────────── + +export type AddABTestStep = + | 'name' + | 'description' + | 'gateway' + | 'variants' + | 'onlineEval' + | 'maxDuration' + | 'enableOnCreate' + | 'confirm'; + +export interface AddABTestConfig { + name: string; + description: string; + gateway: string; + controlBundle: string; + controlVersion: string; + treatmentBundle: string; + treatmentVersion: string; + treatmentWeight: number; + onlineEval: string; + maxDuration: number | undefined; + enableOnCreate: boolean; +} + +export const AB_TEST_STEP_LABELS: Record = { + name: 'Name', + description: 'Description', + gateway: 'Gateway', + variants: 'Variants', + 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..7d6411e4d --- /dev/null +++ b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts @@ -0,0 +1,139 @@ +import type { VariantConfig } from './VariantConfigForm'; +import type { AddABTestConfig, AddABTestStep } from './types'; +import { useCallback, useState } from 'react'; + +const ALL_STEPS: AddABTestStep[] = [ + 'name', + 'description', + 'gateway', + 'variants', + 'onlineEval', + 'maxDuration', + 'enableOnCreate', + 'confirm', +]; + +function getDefaultConfig(): AddABTestConfig { + return { + name: '', + description: '', + gateway: '', + controlBundle: '', + controlVersion: '', + treatmentBundle: '', + treatmentVersion: '', + treatmentWeight: 20, + onlineEval: '', + maxDuration: undefined, + enableOnCreate: true, + }; +} + +export function useAddABTestWizard() { + 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 nextStep = useCallback((currentStep: AddABTestStep): AddABTestStep | undefined => { + const idx = ALL_STEPS.indexOf(currentStep); + return ALL_STEPS[idx + 1]; + }, []); + + const advance = useCallback( + (from: AddABTestStep) => { + const next = nextStep(from); + if (next) setStep(next); + }, + [nextStep] + ); + + 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 setGateway = useCallback( + (gateway: string) => { + setConfig(c => ({ ...c, gateway })); + 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] + ); + + 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('name'); + }, []); + + return { + config, + step, + steps: ALL_STEPS, + currentIndex, + goBack, + setName, + setDescription, + setGateway, + setVariants, + setOnlineEval, + setMaxDuration, + setEnableOnCreate, + reset, + }; +} diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index b0c01b835..c5be8c2ed 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -3,6 +3,7 @@ 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'; @@ -32,6 +33,7 @@ type FlowState = | { name: 'online-eval-wizard' } | { name: 'policy-wizard' } | { name: 'config-bundle-wizard' } + | { name: 'ab-test-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -206,6 +208,9 @@ export function AddFlow(props: AddFlowProps) { case 'config-bundle': setFlow({ name: 'config-bundle-wizard' }); break; + case 'ab-test': + setFlow({ name: 'ab-test-wizard' }); + break; } }, []); @@ -452,6 +457,19 @@ export function AddFlow(props: AddFlowProps) { ); } + // AB test wizard + if (flow.name === 'ab-test-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( ({ diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 0954f00ab..3ce7c0ef5 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, + postDeployWarnings, isDiffLoading, requestDiff, hasError, @@ -362,6 +363,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 767dbccb5..b8985caa6 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -15,6 +15,7 @@ import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from import { ExecLogger } from '../../../logging'; import { performStackTeardown, setupTransactionSearch } from '../../../operations/deploy'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; +import { setupABTests } from '../../../operations/deploy/post-deploy-ab-tests'; import { setupConfigBundles } from '../../../operations/deploy/post-deploy-config-bundles'; import { type StackDiffSummary, @@ -84,6 +85,8 @@ 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[]; /** Whether an on-demand diff is currently running */ isDiffLoading: boolean; /** Request an on-demand diff (lazy: runs once, caches result) */ @@ -130,6 +133,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const [numStacksWithChanges, setNumStacksWithChanges] = useState(); const [isDiffLoading, setIsDiffLoading] = useState(false); const [deployNotes, setDeployNotes] = useState([]); + const [postDeployWarnings, setPostDeployWarnings] = useState([]); const isDiffRunningRef = useRef(false); const [deployOutput, setDeployOutput] = useState(null); const [deployMessages, setDeployMessages] = useState([]); @@ -330,10 +334,51 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState for (const err of errors) { logger.log(`Config bundle "${err.bundleName}" setup error: ${err.error}`, 'warn'); } + 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'); + setPostDeployWarnings(prev => [...prev, `Config bundle 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'); + } + 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'); + setPostDeployWarnings(prev => [...prev, `AB test setup failed: ${message}`]); } } @@ -673,6 +718,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState diffSummaries, numStacksWithChanges, deployNotes, + postDeployWarnings, isDiffLoading, requestDiff, stackOutputs, diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index fe583bae6..a9ba3ff16 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -1,6 +1,7 @@ import type { RemovableGatewayTarget, RemovalPreview } from '../../../operations/remove'; import { ErrorPrompt, Panel, Screen } from '../../components'; import { + useRemovableABTests, useRemovableAgents, useRemovableConfigBundles, useRemovableEvaluators, @@ -12,6 +13,7 @@ import { useRemovablePolicies, useRemovablePolicyEngines, useRemovalPreview, + useRemoveABTest, useRemoveAgent, useRemoveConfigBundle, useRemoveEvaluator, @@ -23,6 +25,7 @@ import { useRemovePolicy, useRemovePolicyEngine, } from '../../hooks/useRemove'; +import { RemoveABTestScreen } from '../ab-test/RemoveABTestScreen'; import { RemoveAgentScreen } from './RemoveAgentScreen'; import { RemoveAllScreen } from './RemoveAllScreen'; import { RemoveConfigBundleScreen } from './RemoveConfigBundleScreen'; @@ -54,6 +57,7 @@ type FlowState = | { name: 'select-policy-engine' } | { name: 'select-policy' } | { name: 'select-config-bundle' } + | { name: 'select-ab-test' } | { name: 'confirm-agent'; agentName: string; preview: RemovalPreview } | { name: 'confirm-gateway'; gatewayName: string; preview: RemovalPreview } | { name: 'confirm-gateway-target'; tool: RemovableGatewayTarget; preview: RemovalPreview } @@ -64,6 +68,7 @@ type FlowState = | { 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: 'loading'; message: string } | { name: 'agent-success'; agentName: string; logFilePath?: string } | { name: 'gateway-success'; gatewayName: string; logFilePath?: string } @@ -75,6 +80,7 @@ type FlowState = | { 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: 'remove-all' } | { name: 'error'; message: string }; @@ -97,7 +103,8 @@ interface RemoveFlowProps { | 'online-eval' | 'policy-engine' | 'policy' - | 'config-bundle'; + | 'config-bundle' + | 'ab-test'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; } @@ -133,6 +140,8 @@ export function RemoveFlow({ return { name: 'select-policy' }; case 'config-bundle': return { name: 'select-config-bundle' }; + case 'ab-test': + return { name: 'select-ab-test' }; default: return { name: 'select' }; } @@ -162,6 +171,7 @@ export function RemoveFlow({ isLoading: isLoadingConfigBundles, refresh: refreshConfigBundles, } = useRemovableConfigBundles(); + const { abTests } = useRemovableABTests(); // Check if any data is still loading const isLoading = @@ -188,6 +198,7 @@ export function RemoveFlow({ loadPolicyEnginePreview, loadPolicyPreview, loadConfigBundlePreview, + loadABTestPreview, reset: resetPreview, } = useRemovalPreview(); @@ -202,6 +213,7 @@ export function RemoveFlow({ const { remove: removePolicyEngineOp, reset: resetRemovePolicyEngine } = useRemovePolicyEngine(); const { remove: removePolicyOp, reset: resetRemovePolicy } = useRemovePolicy(); const { remove: removeConfigBundleOp, reset: resetRemoveConfigBundle } = useRemoveConfigBundle(); + const { remove: removeABTestOp, reset: resetRemoveABTest } = useRemoveABTest(); // Track pending result state const pendingResultRef = useRef(null); @@ -233,6 +245,7 @@ export function RemoveFlow({ 'policy-engine-success', 'policy-success', 'config-bundle-success', + 'ab-test-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -275,6 +288,9 @@ export function RemoveFlow({ case 'config-bundle': setFlow({ name: 'select-config-bundle' }); break; + case 'ab-test': + setFlow({ name: 'select-ab-test' }); + break; case 'all': setFlow({ name: 'remove-all' }); break; @@ -507,6 +523,28 @@ 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 }); + } + } else { + setFlow({ name: 'confirm-ab-test', testName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadABTestPreview, force, removeABTestOp] + ); + // Auto-select resource when initialResourceName is provided and data is loaded useEffect(() => { if (!initialResourceName || isLoading || hasTriggeredInitialSelection.current) { @@ -546,6 +584,9 @@ export function RemoveFlow({ case 'config-bundle': void handleSelectConfigBundle(initialResourceName); break; + case 'ab-test': + void handleSelectABTest(initialResourceName); + break; } }, 0); }, [ @@ -561,6 +602,7 @@ export function RemoveFlow({ handleSelectPolicyEngine, handleSelectPolicy, handleSelectConfigBundle, + handleSelectABTest, ]); // Confirm handlers - pass preview for logging @@ -724,6 +766,22 @@ 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 }; + } + setResultReady(true); + }, + [removeABTestOp] + ); + const resetAll = useCallback(() => { resetPreview(); resetRemoveAgent(); @@ -736,6 +794,7 @@ export function RemoveFlow({ resetRemovePolicyEngine(); resetRemovePolicy(); resetRemoveConfigBundle(); + resetRemoveABTest(); }, [ resetPreview, resetRemoveAgent, @@ -748,6 +807,7 @@ export function RemoveFlow({ resetRemovePolicyEngine, resetRemovePolicy, resetRemoveConfigBundle, + resetRemoveABTest, ]); const refreshAll = useCallback(async () => { @@ -795,6 +855,7 @@ export function RemoveFlow({ policyEngineCount={policyEngines.length} policyCount={policies.length} configBundleCount={configBundles.length} + abTestCount={abTests.length} /> ); } @@ -942,6 +1003,19 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-ab-test') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectABTest(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + // Confirmation screens if (flow.name === 'confirm-agent') { return ( @@ -1053,6 +1127,17 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-ab-test') { + return ( + void handleConfirmABTest(flow.testName, flow.preview)} + onCancel={() => setFlow({ name: 'select-ab-test' })} + /> + ); + } + // Success screens if (flow.name === 'agent-success') { return ( @@ -1214,6 +1299,22 @@ export function RemoveFlow({ ); } + if (flow.name === 'ab-test-success') { + return ( + { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + // Remove all screen if (flow.name === 'remove-all') { return ; @@ -1223,7 +1324,7 @@ export function RemoveFlow({ return ( { resetAll(); setFlow({ name: 'select' }); diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index ff102af07..8ac1a57aa 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -13,6 +13,7 @@ const REMOVE_RESOURCES = [ { 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', description: 'Remove a configuration bundle' }, + { id: 'ab-test', title: 'AB Test', description: 'Remove an A/B test' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; @@ -41,6 +42,8 @@ interface RemoveScreenProps { policyCount: number; /** Number of configuration bundles available for removal */ configBundleCount: number; + /** Number of AB tests available for removal */ + abTestCount: number; } export function RemoveScreen({ @@ -56,6 +59,7 @@ export function RemoveScreen({ policyEngineCount, policyCount, configBundleCount, + abTestCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { @@ -123,6 +127,12 @@ 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 'all': // 'all' is always available break; @@ -141,6 +151,7 @@ export function RemoveScreen({ policyEngineCount, policyCount, configBundleCount, + abTestCount, ]); 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 ff144ade1..237e802e2 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -22,6 +22,7 @@ describe('RemoveScreen', () => { policyEngineCount={1} policyCount={1} configBundleCount={1} + abTestCount={0} /> ); @@ -53,6 +54,7 @@ describe('RemoveScreen', () => { policyEngineCount={0} policyCount={0} configBundleCount={0} + abTestCount={0} /> ); @@ -61,4 +63,55 @@ 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/utils/commands.ts b/src/cli/tui/utils/commands.ts index 4976e7706..2b971d8bb 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -17,7 +17,7 @@ const HIDDEN_FROM_TUI = ['help'] 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'] as const; /** * Commands hidden from TUI when inside an existing project. diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index b47459748..b8ee03d83 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -9,6 +9,7 @@ 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 { @@ -297,6 +298,16 @@ export const AgentCoreProjectSpecSchema = z name => `Duplicate config bundle name: ${name}` ) ), + + abTests: z + .array(ABTestSchema) + .default([]) + .superRefine( + uniqueBy( + test => test.name, + name => `Duplicate AB test name: ${name}` + ) + ), }) .strict() .superRefine((spec, ctx) => { diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 28e5543ea..ee2e8aa70 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -180,6 +180,21 @@ export const ConfigBundleDeployedStateSchema = z.object({ 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(), +}); + +export type ABTestDeployedState = z.infer; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -193,6 +208,7 @@ export const DeployedResourceStateSchema = z.object({ 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(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), stackName: z.string().optional(), diff --git a/src/schema/schemas/primitives/__tests__/ab-test.test.ts b/src/schema/schemas/primitives/__tests__/ab-test.test.ts new file mode 100644 index 000000000..ef574a0b9 --- /dev/null +++ b/src/schema/schemas/primitives/__tests__/ab-test.test.ts @@ -0,0 +1,228 @@ +import { + ABTestDescriptionSchema, + ABTestNameSchema, + ABTestSchema, + VariantNameSchema, + VariantWeightSchema, +} from '../ab-test'; +import { describe, expect, it } from 'vitest'; + +describe('ABTestNameSchema', () => { + 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', + gatewayArn: '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/ab-test.ts b/src/schema/schemas/primitives/ab-test.ts new file mode 100644 index 000000000..997667e47 --- /dev/null +++ b/src/schema/schemas/primitives/ab-test.ts @@ -0,0 +1,83 @@ +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 VariantNameSchema = z.enum(['C', 'T1']); + +export const VariantWeightSchema = z.number().int().min(1).max(100); + +export const ConfigurationBundleRefSchema = z.object({ + bundleArn: z.string().min(1), + bundleVersion: z.string().min(1), +}); + +export type ConfigurationBundleRef = z.infer; + +export const VariantConfigurationSchema = z.object({ + configurationBundle: ConfigurationBundleRefSchema, +}); + +export type VariantConfiguration = z.infer; + +export const ABTestVariantSchema = z.object({ + name: VariantNameSchema, + weight: VariantWeightSchema, + variantConfiguration: VariantConfigurationSchema, +}); + +export type ABTestVariant = z.infer; + +export const ABTestEvaluationConfigSchema = z.object({ + onlineEvaluationConfigArn: z.string().min(1), +}); + +export type ABTestEvaluationConfig = z.infer; + +export const TrafficRouteOnHeaderSchema = z.object({ + headerName: z.string().min(1), +}); + +export const TrafficAllocationConfigSchema = z.object({ + routeOnHeader: TrafficRouteOnHeaderSchema, +}); + +export type TrafficAllocationConfig = z.infer; + +export const ABTestSchema = z + .object({ + name: ABTestNameSchema, + description: ABTestDescriptionSchema, + gatewayArn: z.string().min(1), + roleArn: z.string().min(1).optional(), + variants: z.array(ABTestVariantSchema).length(2), + evaluationConfig: ABTestEvaluationConfigSchema, + trafficAllocationConfig: TrafficAllocationConfigSchema.optional(), + maxDurationDays: z.number().int().min(1).max(90).optional(), + enableOnCreate: 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'], + }); + +export type ABTest = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index e14a0f248..c93a844f1 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, From a0c9bc6b78846d1b6843ae85b0bcf1455849563e Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 9 Apr 2026 11:28:34 -0400 Subject: [PATCH 21/64] 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. --- src/cli/tui/copy.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 3465330c5..9a16c571e 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -111,14 +111,6 @@ export const CLI_ONLY_EXAMPLES: Record Date: Thu, 9 Apr 2026 12:25:49 -0400 Subject: [PATCH 22/64] 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 --- .../screens/ab-test/ABTestDetailScreen.tsx | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx index 5f38d28ba..7d052163c 100644 --- a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx +++ b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx @@ -25,9 +25,14 @@ function shortVersion(version: string): string { return version.slice(0, 8); } -/** Pad a string to a fixed width. */ -function pad(str: string, width: number): string { - return str.length >= width ? str : str + ' '.repeat(width - str.length); +/** 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. */ @@ -191,16 +196,36 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr {test.results ? ( <> - {rule('Results', test.results.analysisTimestamp)} + + {rule( + 'Results', + test.results.analysisTimestamp ? formatTimestamp(test.results.analysisTimestamp) : undefined + )} + - {`${pad('', 15)}${pad('Control', 12)}${pad('Treatment', 12)}Δ`} + + {''} + + + {'Control'} + + + {'Treatment'} + + {'Δ'} {test.results.evaluatorMetrics.map((metric, i) => ( 0 ? 1 : 0}> - {pad(extractId(metric.evaluatorArn), 15)} - {pad(metric.controlStats.mean.toFixed(4), 12)} - {pad(metric.variantResults[0]?.mean.toFixed(4) ?? '', 12)} + + {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)}% ✓`} ) : ( @@ -208,9 +233,15 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr )} - {pad('', 15)} - {pad(`n=${metric.controlStats.sampleSize}`, 12)} - {pad(`n=${metric.variantResults[0]?.sampleSize ?? ''}`, 12)} + + {''} + + + {`n=${metric.controlStats.sampleSize}`} + + + {`n=${metric.variantResults[0]?.sampleSize ?? ''}`} + {`p=${metric.variantResults[0]?.pValue?.toFixed(3) ?? 'N/A'}`} From 7cfa55e3340c53a2d977c624519fd8c662f00e13 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:31:46 -0400 Subject: [PATCH 23/64] 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. --- .../recommendation/run-recommendation.ts | 3 + src/cli/operations/recommendation/types.ts | 2 + .../recommendation/RecommendationFlow.tsx | 120 ++++++++++++++---- .../recommendation/RecommendationScreen.tsx | 72 ++++++++++- src/cli/tui/screens/recommendation/types.ts | 13 ++ .../recommendation/useRecommendationWizard.ts | 13 ++ .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 2 + 7 files changed, 193 insertions(+), 32 deletions(-) diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index d508d229a..a358ac49e 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -93,6 +93,7 @@ export async function runRecommendationCommand( inlineContent, bundleName: options.bundleName, bundleVersion: options.bundleVersion, + systemPromptJsonPath: options.systemPromptJsonPath, inputSource: options.inputSource, tools: options.tools, traceSource: options.traceSource, @@ -298,6 +299,7 @@ interface BuildConfigOptions { inlineContent?: string; bundleName?: string; bundleVersion?: string; + systemPromptJsonPath?: string; inputSource: string; tools?: string[]; traceSource: string; @@ -392,6 +394,7 @@ async function buildRecommendationConfig(opts: BuildConfigOptions): Promise { - const isToolDescWithSessions = - config.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && config.traceSource === 'sessions'; - - const initialSteps: Step[] = [ - ...(isToolDescWithSessions - ? [{ 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 (isToolDescWithSessions) { - initialSteps[0] = { ...initialSteps[0]!, status: 'running' }; - initialSteps[1] = { ...initialSteps[1]!, status: 'pending' }; - } + const handleRunComplete = useCallback( + (config: RecommendationWizardConfig) => { + const isToolDescWithSessions = + config.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && config.traceSource === 'sessions'; + + const initialSteps: Step[] = [ + ...(isToolDescWithSessions + ? [{ 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 (isToolDescWithSessions) { + initialSteps[0] = { ...initialSteps[0]!, status: 'running' }; + initialSteps[1] = { ...initialSteps[1]!, status: 'pending' }; + } - setFlow({ name: 'running', config, steps: initialSteps, elapsed: 0 }); - }, []); + // Carry configBundles from wizard state so the running effect can look up systemPrompt + const bundles = flow.name === 'wizard' ? flow.configBundles : []; + setFlow({ name: 'running', config, configBundles: bundles, steps: initialSteps, elapsed: 0 }); + }, + [flow] + ); // Execute the recommendation when entering 'running' state useEffect(() => { if (flow.name !== 'running') return; let cancelled = false; - const { config } = flow; + const { config, configBundles } = flow; const startTime = Date.now(); const timer = setInterval(() => { @@ -118,14 +132,25 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { } }, 1000); + // For config-bundle input, look up the system prompt from the loaded config bundles + const bundleSystemPrompt = + config.inputSource === 'config-bundle' + ? configBundles.find(cb => cb.bundleArn === config.bundleName)?.systemPrompt + : undefined; + 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, + inputSource: config.inputSource === 'config-bundle' ? 'inline' : config.inputSource, + inlineContent: + config.inputSource === 'inline' + ? config.content + : config.inputSource === 'config-bundle' + ? bundleSystemPrompt + : undefined, promptFile: config.inputSource === 'file' ? config.content : undefined, tools: config.tools ? config.tools @@ -246,6 +271,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { @@ -426,3 +452,43 @@ function buildAgentItems(deployedState: DeployedState): AgentItem[] { return agents; } + +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); + + // Extract systemPrompt from matching project config bundle + let systemPrompt: string | undefined; + const projBundle = projectBundles.find(pb => pb.name === name); + if (projBundle?.components) { + for (const comp of Object.values(projBundle.components)) { + const sp = comp?.configuration?.systemPrompt; + if (typeof sp === 'string') { + systemPrompt = sp; + break; + } + } + } + + bundles.push({ + name, + bundleId: state.bundleId, + bundleArn: state.bundleArn, + versionId: state.versionId, + systemPrompt, + }); + } + } + + return bundles; +} diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx index d4716d4c0..d62fcdae4 100644 --- a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -16,7 +16,7 @@ import { } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; -import type { AgentItem, EvaluatorItem, RecommendationWizardConfig } from './types'; +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'; @@ -25,11 +25,18 @@ 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, onComplete, onExit }: RecommendationScreenProps) { +export function RecommendationScreen({ + agents, + evaluators, + configBundles, + onComplete, + onExit, +}: RecommendationScreenProps) { const wizard = useRecommendationWizard(); // ── Selectable items ────────────────────────────────────────────────────── @@ -74,6 +81,11 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: () => [ { 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', + }, ], [] ); @@ -111,6 +123,7 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: const isEvaluatorStep = wizard.step === 'evaluator'; const isInputSourceStep = wizard.step === 'inputSource'; const isContentStep = wizard.step === 'content'; + const isBundleStep = wizard.step === 'bundle'; const isToolsStep = wizard.step === 'tools'; const isTraceSourceStep = wizard.step === 'traceSource'; const isDaysStep = wizard.step === 'days'; @@ -219,11 +232,31 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: const inputSourceNav = useListNavigation({ items: inputSourceItems, - onSelect: item => wizard.setInputSource(item.id as 'inline' | 'file'), + 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, + }); + const traceSourceNav = useListNavigation({ items: traceSourceItems, onSelect: item => wizard.setTraceSource(item.id as 'cloudwatch' | 'sessions'), @@ -265,7 +298,7 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: : sessionPhase === 'error' ? HELP_TEXT.CONFIRM_CANCEL : 'Space toggle · Enter confirm · Esc back' - : isTypeStep || isAgentStep || isInputSourceStep || isTraceSourceStep + : isTypeStep || isAgentStep || isInputSourceStep || isTraceSourceStep || isBundleStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -289,7 +322,15 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: }, ] : []), - { label: 'Input', value: wizard.config.inputSource === 'file' ? `File: ${wizard.config.content}` : 'Inline' }, + { + 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}` + : 'Inline', + }, { label: 'Traces', value: @@ -340,6 +381,7 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: items={evaluatorItems} selectedIndex={evaluatorNav.selectedIndex} emptyMessage="No evaluators available." + maxVisibleItems={10} /> )} @@ -376,6 +418,26 @@ export function RecommendationScreen({ agents, evaluators, onComplete, onExit }: /> )} + {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 && ( + + )} + {isToolsStep && ( Enter tool names and descriptions as comma-separated toolName:description pairs. diff --git a/src/cli/tui/screens/recommendation/types.ts b/src/cli/tui/screens/recommendation/types.ts index cdd96dfa7..5a1b8fd09 100644 --- a/src/cli/tui/screens/recommendation/types.ts +++ b/src/cli/tui/screens/recommendation/types.ts @@ -10,6 +10,7 @@ export type RecommendationStep = | 'evaluator' | 'inputSource' | 'content' + | 'bundle' | 'tools' | 'traceSource' | 'days' @@ -26,6 +27,8 @@ export interface RecommendationWizardConfig { traceSource: TraceSourceKind; days: number; sessionIds: string[]; + bundleName: string; + bundleVersion: string; } export const RECOMMENDATION_STEP_LABELS: Record = { @@ -34,6 +37,7 @@ export const RECOMMENDATION_STEP_LABELS: Record = { evaluator: 'Evaluator', inputSource: 'Source', content: 'Content', + bundle: 'Bundle', tools: 'Tools', traceSource: 'Traces', days: 'Lookback', @@ -54,3 +58,12 @@ export interface EvaluatorItem { title: string; description: string; } + +export interface ConfigBundleItem { + name: string; + bundleId: string; + bundleArn: string; + versionId: string; + /** System prompt extracted from the local project config (first component with a systemPrompt field). */ + systemPrompt?: string; +} diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts index 6f9416c82..0bb321800 100644 --- a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -25,6 +25,8 @@ function getAllSteps( steps.push('inputSource'); if (inputSource === 'inline' || inputSource === 'file') { steps.push('content'); + } else if (inputSource === 'config-bundle') { + steps.push('bundle'); } } @@ -63,6 +65,8 @@ function getDefaultConfig(): RecommendationWizardConfig { traceSource: 'cloudwatch', days: DEFAULT_LOOKBACK_DAYS, sessionIds: [], + bundleName: '', + bundleVersion: '', }; } @@ -163,6 +167,14 @@ export function useRecommendationWizard() { [advance] ); + const setBundle = useCallback( + (bundleName: string, bundleVersion: string) => { + setConfig(c => ({ ...c, bundleName, bundleVersion })); + advance('bundle'); + }, + [advance] + ); + const setSessions = useCallback( (sessionIds: string[]) => { setConfig(c => ({ ...c, sessionIds })); @@ -187,6 +199,7 @@ export function useRecommendationWizard() { setEvaluators, setInputSource, setContent, + setBundle, setTools, setTraceSource, setDays, diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 1c61a0278..e2d8221b8 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -424,6 +424,7 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit items={evaluatorItems} cursorIndex={evaluatorsNav.cursorIndex} selectedIds={evaluatorsNav.selectedIds} + maxVisibleItems={10} /> )} @@ -434,6 +435,7 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit key="name" prompt="Batch evaluation name" initialValue="" + allowEmpty onSubmit={value => { setConfig(c => ({ ...c, name: value })); goNext(); From 9cb90a57cf766fef7d89b6b190a5e89860c906dc Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:50:26 -0400 Subject: [PATCH 24/64] fix: single evaluator + config bundle field selection for recommendations (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../agentcore-recommendation.test.ts | 8 +- src/cli/aws/agentcore-batch-evaluation.ts | 11 ++ src/cli/aws/agentcore-recommendation.ts | 6 +- src/cli/commands/run/command.tsx | 22 ++- .../operations/eval/run-batch-evaluation.ts | 25 ++- .../recommendation/run-recommendation.ts | 28 ++- src/cli/operations/recommendation/types.ts | 2 +- .../recommendation/RecommendationFlow.tsx | 66 +++++-- .../recommendation/RecommendationScreen.tsx | 121 ++++++++++-- src/cli/tui/screens/recommendation/types.ts | 7 +- .../recommendation/useRecommendationWizard.ts | 30 ++- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 183 +++++++++++++++++- 12 files changed, 433 insertions(+), 76 deletions(-) diff --git a/src/cli/aws/__tests__/agentcore-recommendation.test.ts b/src/cli/aws/__tests__/agentcore-recommendation.test.ts index 340c10f37..1b330cf30 100644 --- a/src/cli/aws/__tests__/agentcore-recommendation.test.ts +++ b/src/cli/aws/__tests__/agentcore-recommendation.test.ts @@ -122,7 +122,9 @@ describe('agentcore-recommendation', () => { endTime: '2026-03-30T00:00:00.000Z', }, }, - evaluationConfig: { evaluators: [] }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, }, }, }); @@ -158,7 +160,9 @@ describe('agentcore-recommendation', () => { endTime: '2026-03-30T00:00:00.000Z', }, }, - evaluationConfig: { evaluators: [] }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, }, }, }); diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index 52e38c1d6..ba8a32d65 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -22,9 +22,20 @@ import { SignatureV4 } from '@smithy/signature-v4'; // Types // ============================================================================ +export interface SessionFilterConfig { + startTime?: string; + endTime?: string; +} + +export interface CloudWatchSessionInput { + sessionIds?: string[]; + sessionFilterConfig?: SessionFilterConfig; +} + export interface CloudWatchSource { serviceNames: string[]; logGroupNames: string[]; + sessionInput?: CloudWatchSessionInput; } export interface BatchEvaluationConfig { diff --git a/src/cli/aws/agentcore-recommendation.ts b/src/cli/aws/agentcore-recommendation.ts index 61fd223a7..7ed98b83f 100644 --- a/src/cli/aws/agentcore-recommendation.ts +++ b/src/cli/aws/agentcore-recommendation.ts @@ -66,11 +66,9 @@ export interface AgentTracesSource { }; } -/** Evaluation config — which evaluator(s) to use as objective signal. */ +/** Evaluation config — exactly one evaluator as objective signal (API constraint: min 1, max 1). */ export interface RecommendationEvaluationConfig { - evaluators: { - evaluatorArn: string; - }[]; + evaluators: [{ evaluatorArn: string }]; } /** Config for SYSTEM_PROMPT_RECOMMENDATION type. */ diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index 334082f62..37caf216c 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -168,6 +168,8 @@ export const registerRun = (program: Command) => { .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('--region ', 'AWS region (auto-detected if omitted)') .option('--execution-role ', 'IAM execution role ARN for batch evaluation') .option('--json', 'Output as JSON') @@ -176,6 +178,8 @@ export const registerRun = (program: Command) => { runtime: string; evaluator: string[]; name?: string; + lookbackDays?: string; + sessionIds?: string[]; region?: string; executionRole?: string; json?: boolean; @@ -183,12 +187,15 @@ export const registerRun = (program: Command) => { requireProject(); try { + 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, executionRoleArn: cliOptions.executionRole, + sessionIds: cliOptions.sessionIds, + lookbackDays: lookbackDays && !isNaN(lookbackDays) ? lookbackDays : undefined, onProgress: cliOptions.json ? undefined : (_status, message) => { @@ -233,10 +240,7 @@ export const registerRun = (program: Command) => { .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(s) — required for system-prompt, optional for tool-description' - ) + .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') @@ -255,7 +259,7 @@ export const registerRun = (program: Command) => { async (cliOptions: { type?: string; runtime?: string; - evaluator?: string[]; + evaluator?: string; promptFile?: string; inline?: string; bundleName?: string; @@ -283,7 +287,7 @@ export const registerRun = (program: Command) => { } const agent = cliOptions.runtime; - const evaluators = cliOptions.evaluator; + const evaluator = cliOptions.evaluator; if (!agent) { const error = '--runtime is required'; @@ -296,7 +300,7 @@ export const registerRun = (program: Command) => { } // Evaluator is required for system-prompt recs, optional for tool-description - if (recType === 'SYSTEM_PROMPT_RECOMMENDATION' && (!evaluators || evaluators.length === 0)) { + 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 })); @@ -324,7 +328,7 @@ export const registerRun = (program: Command) => { const result = await runRecommendationCommand({ type: recType, agent, - evaluators: evaluators ?? [], + evaluators: evaluator ? [evaluator] : [], promptFile: cliOptions.promptFile, inlineContent: cliOptions.inline, bundleName: cliOptions.bundleName, @@ -351,7 +355,7 @@ export const registerRun = (program: Command) => { // Save results locally try { if (result.recommendationId) { - saveRecommendationRun(result.recommendationId, result, recType, agent, evaluators ?? []); + saveRecommendationRun(result.recommendationId, result, recType, agent, evaluator ? [evaluator] : []); } } catch { // Non-fatal — skip saving diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index d561cb345..3faac7029 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -9,7 +9,11 @@ import { ConfigIO } from '../../../lib'; import type { DeployedState } from '../../../schema'; import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import type { EvaluationResults, GetBatchEvaluationResult } from '../../aws/agentcore-batch-evaluation'; +import type { + CloudWatchSessionInput, + EvaluationResults, + GetBatchEvaluationResult, +} 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'; @@ -29,6 +33,10 @@ export interface RunBatchEvaluationOptions { region?: string; /** Explicit execution role ARN (falls back to agent's deployed role) */ executionRoleArn?: 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; /** Poll interval in ms */ pollIntervalMs?: number; /** Progress callback */ @@ -131,6 +139,20 @@ export async function runBatchEvaluationCommand( onProgress?.('starting', `Starting batch evaluation "${evalName}"...`); + // Build optional session input for CloudWatch filtering + // API requires either sessionIds OR sessionFilterConfig, not both — sessionIds takes precedence + const sessionInput: CloudWatchSessionInput | undefined = (() => { + if (options.sessionIds && options.sessionIds.length > 0) { + return { sessionIds: options.sessionIds }; + } + if (options.lookbackDays) { + const endTime = new Date().toISOString(); + const startTime = new Date(Date.now() - options.lookbackDays * 24 * 60 * 60 * 1000).toISOString(); + return { sessionFilterConfig: { startTime, endTime } }; + } + return undefined; + })(); + const startPayload = { region, name: evalName, @@ -141,6 +163,7 @@ export async function runBatchEvaluationCommand( cloudWatchSource: { serviceNames: [serviceName], logGroupNames: [runtimeLogGroup], + ...(sessionInput ? { sessionInput } : {}), }, }, ...(options.executionRoleArn ? { executionRoleArn: options.executionRoleArn } : {}), diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index a358ac49e..abd7dca7b 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -60,7 +60,7 @@ export async function runRecommendationCommand( } logger?.log(`Agent: ${options.agent} (runtime: ${agentState.runtimeId})`); - // 3. Resolve evaluator IDs/ARNs + // 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); @@ -73,7 +73,14 @@ export async function runRecommendationCommand( } evaluatorIds.push(evaluatorId); } - logger?.log(`Evaluators: ${evaluatorIds.join(', ')}`); + 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) @@ -84,6 +91,19 @@ export async function runRecommendationCommand( 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); @@ -381,8 +401,8 @@ async function buildRecommendationConfig(opts: BuildConfigOptions): Promise ({ evaluatorArn: id })), + const evaluationConfig: import('../../aws/agentcore-recommendation').RecommendationEvaluationConfig = { + evaluators: [{ evaluatorArn: opts.evaluatorIds[0]! }], }; if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION') { diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts index 310a0a9af..dbba336a6 100644 --- a/src/cli/operations/recommendation/types.ts +++ b/src/cli/operations/recommendation/types.ts @@ -16,7 +16,7 @@ export interface RunRecommendationCommandOptions { type: RecommendationType; /** Agent name (from project) */ agent: string; - /** Evaluator names, Builtin.* IDs, or ARNs */ + /** Evaluator name, Builtin.* ID, or ARN (API accepts exactly one for system-prompt) */ evaluators: string[]; /** Input source kind */ inputSource: RecommendationInputSourceKind; diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index 4a56c5158..c37982db5 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -132,14 +132,39 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { } }, 1000); - // For config-bundle input, look up the system prompt from the loaded config bundles - const bundleSystemPrompt = - config.inputSource === 'config-bundle' - ? configBundles.find(cb => cb.bundleArn === config.bundleName)?.systemPrompt - : undefined; + // For config-bundle input, look up selected field values from the loaded config bundles + const selectedBundle = + config.inputSource === 'config-bundle' ? configBundles.find(cb => cb.bundleArn === config.bundleName) : undefined; void (async () => { try { + // Resolve inline content and tools from config bundle fields + let resolvedInlineContent: string | undefined; + let resolvedTools: string[] | undefined; + + if (config.inputSource === 'config-bundle' && selectedBundle) { + if (config.type === 'SYSTEM_PROMPT_RECOMMENDATION') { + // System prompt: single field → use its value as inline content + const fieldName = config.bundleFields[0]; + const fieldValue = fieldName ? selectedBundle.stringFields[fieldName] : undefined; + if (!fieldValue) { + throw new Error(`Field "${fieldName}" not found or empty in the selected config bundle.`); + } + resolvedInlineContent = fieldValue; + } else { + // Tool description: multiple fields → each field becomes toolName:description + resolvedTools = config.bundleFields.map(fieldName => { + const value = selectedBundle.stringFields[fieldName]; + if (!value) { + throw new Error(`Field "${fieldName}" not found or empty in the selected config bundle.`); + } + return `${fieldName}:${value}`; + }); + } + } else if (config.inputSource === 'config-bundle') { + throw new Error('Selected config bundle not found.'); + } + const result = await runRecommendationCommand({ type: config.type, agent: config.agent, @@ -149,15 +174,17 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { config.inputSource === 'inline' ? config.content : config.inputSource === 'config-bundle' - ? bundleSystemPrompt + ? resolvedInlineContent : undefined, promptFile: config.inputSource === 'file' ? config.content : undefined, - tools: config.tools - ? config.tools - .split(/,(?=[a-zA-Z0-9_\-.]+:)/) - .map(t => t.trim()) - .filter(Boolean) - : undefined, + tools: + resolvedTools ?? + (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, @@ -467,15 +494,16 @@ function buildConfigBundleItems( if (seen.has(name)) continue; seen.add(name); - // Extract systemPrompt from matching project config bundle - let systemPrompt: string | undefined; + // Collect all string-valued configuration fields across components + const stringFields: Record = {}; const projBundle = projectBundles.find(pb => pb.name === name); if (projBundle?.components) { for (const comp of Object.values(projBundle.components)) { - const sp = comp?.configuration?.systemPrompt; - if (typeof sp === 'string') { - systemPrompt = sp; - break; + if (!comp?.configuration) continue; + for (const [key, value] of Object.entries(comp.configuration)) { + if (typeof value === 'string' && value.trim().length > 0) { + stringFields[key] = value; + } } } } @@ -485,7 +513,7 @@ function buildConfigBundleItems( bundleId: state.bundleId, bundleArn: state.bundleArn, versionId: state.versionId, - systemPrompt, + stringFields, }); } } diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx index d62fcdae4..3066ec82b 100644 --- a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -77,21 +77,31 @@ export function RecommendationScreen({ [evaluators] ); + const isToolDesc = wizard.config.type === 'TOOL_DESCRIPTION_RECOMMENDATION'; + const inputSourceItems: SelectableItem[] = useMemo( - () => [ - { 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 + ? [ + { 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 isToolDesc = wizard.config.type === 'TOOL_DESCRIPTION_RECOMMENDATION'; - const traceSourceItems: SelectableItem[] = useMemo( () => isToolDesc @@ -124,6 +134,7 @@ export function RecommendationScreen({ 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'; @@ -257,6 +268,36 @@ export function RecommendationScreen({ isActive: isBundleStep, }); + // Build selectable items for string fields in the selected config bundle + const bundleFieldItems: SelectableItem[] = useMemo(() => { + const selectedBundle = configBundles.find(cb => cb.bundleArn === wizard.config.bundleName); + if (!selectedBundle) return []; + return Object.entries(selectedBundle.stringFields).map(([key, value]) => ({ + id: key, + title: key, + description: value.length > 80 ? value.slice(0, 80) + '…' : value, + })); + }, [configBundles, wizard.config.bundleName]); + + // 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 => wizard.setBundleFields([item.id]), + 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 => wizard.setBundleFields(ids), + onExit: () => wizard.goBack(), + isActive: isBundleFieldStep && !useFieldSingleSelect, + requireSelection: true, + }); + const traceSourceNav = useListNavigation({ items: traceSourceItems, onSelect: item => wizard.setTraceSource(item.id as 'cloudwatch' | 'sessions'), @@ -298,11 +339,13 @@ export function RecommendationScreen({ : sessionPhase === 'error' ? HELP_TEXT.CONFIRM_CANCEL : 'Space toggle · Enter confirm · Esc back' - : isTypeStep || isAgentStep || isInputSourceStep || isTraceSourceStep || isBundleStep - ? HELP_TEXT.NAVIGATE_SELECT - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : HELP_TEXT.TEXT_INPUT; + : 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 = ( @@ -328,7 +371,7 @@ export function RecommendationScreen({ 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}` + ? `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', }, { @@ -340,7 +383,7 @@ export function RecommendationScreen({ }, ]; - if (!isSystemPrompt) { + if (!isSystemPrompt && wizard.config.inputSource !== 'config-bundle') { confirmFields.push({ label: 'Tools', value: wizard.config.tools || '(none)' }); } @@ -431,13 +474,51 @@ export function RecommendationScreen({ {isBundleStep && configBundles.length > 0 && ( )} + {isBundleFieldStep && bundleFieldItems.length === 0 && ( + + Select prompt field + No string 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. diff --git a/src/cli/tui/screens/recommendation/types.ts b/src/cli/tui/screens/recommendation/types.ts index 5a1b8fd09..aef4920b3 100644 --- a/src/cli/tui/screens/recommendation/types.ts +++ b/src/cli/tui/screens/recommendation/types.ts @@ -11,6 +11,7 @@ export type RecommendationStep = | 'inputSource' | 'content' | 'bundle' + | 'bundleField' | 'tools' | 'traceSource' | 'days' @@ -29,6 +30,7 @@ export interface RecommendationWizardConfig { sessionIds: string[]; bundleName: string; bundleVersion: string; + bundleFields: string[]; } export const RECOMMENDATION_STEP_LABELS: Record = { @@ -38,6 +40,7 @@ export const RECOMMENDATION_STEP_LABELS: Record = { inputSource: 'Source', content: 'Content', bundle: 'Bundle', + bundleField: 'Fields', tools: 'Tools', traceSource: 'Traces', days: 'Lookback', @@ -64,6 +67,6 @@ export interface ConfigBundleItem { bundleId: string; bundleArn: string; versionId: string; - /** System prompt extracted from the local project config (first component with a systemPrompt field). */ - systemPrompt?: string; + /** All string-valued configuration fields across components, keyed by field name. */ + stringFields: Record; } diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts index 0bb321800..0bee61af2 100644 --- a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -19,20 +19,24 @@ function getAllSteps( steps.push('evaluator'); } - // For system prompt: ask input source and content - // For tool description: skip inputSource/content (tools step handles it) + // Input source selection (both types support inline and config-bundle) + steps.push('inputSource'); + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') { - steps.push('inputSource'); 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'); } - } - - // Tools step only for tool description recommendations - if (type === 'TOOL_DESCRIPTION_RECOMMENDATION') { - steps.push('tools'); } steps.push('traceSource'); @@ -67,6 +71,7 @@ function getDefaultConfig(): RecommendationWizardConfig { sessionIds: [], bundleName: '', bundleVersion: '', + bundleFields: [], }; } @@ -175,6 +180,14 @@ export function useRecommendationWizard() { [advance] ); + const setBundleFields = useCallback( + (bundleFields: string[]) => { + setConfig(c => ({ ...c, bundleFields })); + advance('bundleField'); + }, + [advance] + ); + const setSessions = useCallback( (sessionIds: string[]) => { setConfig(c => ({ ...c, sessionIds })); @@ -200,6 +213,7 @@ export function useRecommendationWizard() { setInputSource, setContent, setBundle, + setBundleFields, setTools, setTraceSource, setDays, diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index e2d8221b8..1d815805f 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -2,13 +2,15 @@ import { validateAwsCredentials } from '../../../aws/account'; 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 } from '../../../operations/resolve-agent'; +import { loadDeployedProjectConfig, resolveAgent } from '../../../operations/resolve-agent'; import { ConfirmReview, ErrorPrompt, @@ -27,24 +29,30 @@ import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import type { EvaluatorItem } from '../online-eval/types'; import type { AgentItem } from './types'; import { Box, Text } from 'ink'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; // ============================================================================ // Types // ============================================================================ -type BatchEvalStep = 'agent' | 'evaluators' | 'name' | 'confirm'; +const DEFAULT_LOOKBACK_DAYS = 7; + +type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'name' | 'confirm'; interface BatchEvalConfig { agent: string; evaluators: string[]; evaluatorNames: string[]; + days: number; + sessionIds: string[]; name: string; } const STEP_LABELS: Record = { agent: 'Agent', evaluators: 'Evaluators', + days: 'Lookback', + sessions: 'Sessions', name: 'Name', confirm: 'Confirm', }; @@ -172,6 +180,8 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { agent: config.agent, evaluators: config.evaluators, name: config.name || undefined, + sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, + lookbackDays: config.days, onProgress: (status, _message) => { if (cancelled) return; setFlow(prev => { @@ -317,7 +327,10 @@ interface BatchEvalWizardProps { function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit }: BatchEvalWizardProps) { const skipAgent = agents.length <= 1; const allSteps = useMemo( - () => (skipAgent ? ['evaluators', 'name', 'confirm'] : ['agent', 'evaluators', 'name', 'confirm']), + () => + skipAgent + ? ['evaluators', 'days', 'sessions', 'name', 'confirm'] + : ['agent', 'evaluators', 'days', 'sessions', 'name', 'confirm'], [skipAgent] ); @@ -326,6 +339,8 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit agent: skipAgent ? agents[0]!.name : '', evaluators: [], evaluatorNames: [], + days: DEFAULT_LOOKBACK_DAYS, + sessionIds: [], name: '', }); @@ -357,11 +372,95 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit [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 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 { region } = await detectRegion(); + 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 => { @@ -388,6 +487,26 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit 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, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(config), @@ -399,9 +518,17 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit ? HELP_TEXT.NAVIGATE_SELECT : isEvaluatorsStep ? 'Space toggle · Enter confirm · Esc back' - : isNameStep + : isDaysStep ? HELP_TEXT.TEXT_INPUT - : HELP_TEXT.CONFIRM_CANCEL; + : isSessionsStep + ? sessionPhase === 'loading' + ? '' + : sessionPhase === 'error' + ? HELP_TEXT.CONFIRM_CANCEL + : 'Space toggle · Enter confirm · Esc back' + : isNameStep + ? HELP_TEXT.TEXT_INPUT + : HELP_TEXT.CONFIRM_CANCEL; const headerContent = ; @@ -428,6 +555,45 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit /> )} + {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' && ( + + )} + {isNameStep && ( Optional — leave blank for auto-generated name. @@ -450,6 +616,11 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit fields={[ { label: 'Agent', value: config.agent }, { label: 'Evaluators', value: config.evaluatorNames.join(', ') }, + { label: 'Lookback', value: `${config.days} day${config.days !== 1 ? 's' : ''}` }, + { + label: 'Sessions', + value: `${config.sessionIds.length} selected`, + }, ...(config.name ? [{ label: 'Name', value: config.name }] : []), ]} /> From a6f2bc9c3d0f444d88d5217737ebebbf9c60246c Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:08:33 -0400 Subject: [PATCH 25/64] feat: batch eval ground truth + config bundle & status fixes (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- src/cli/aws/agentcore-batch-evaluation.ts | 41 ++++ src/cli/commands/config-bundle/command.tsx | 12 +- src/cli/commands/run/command.tsx | 41 +++- src/cli/commands/status/command.tsx | 26 ++- .../config-bundle/resolve-bundle.ts | 20 +- .../operations/eval/run-batch-evaluation.ts | 41 +++- src/cli/tui/components/PathInput.tsx | 24 ++- .../config-bundle-hub/useConfigBundleHub.ts | 50 ++++- .../config-bundle/AddConfigBundleScreen.tsx | 37 ++-- .../recommendation/RecommendationFlow.tsx | 6 +- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 193 ++++++++++++++++-- 11 files changed, 423 insertions(+), 68 deletions(-) diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index ba8a32d65..d140dc138 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -42,6 +42,43 @@ export interface BatchEvaluationConfig { evaluators: { 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; +} + export interface StartBatchEvaluationOptions { region: string; name: string; @@ -49,6 +86,7 @@ export interface StartBatchEvaluationOptions { sessionSource: { cloudWatchSource: CloudWatchSource; }; + sessionMetadata?: SessionMetadataEntry[]; executionRoleArn?: string; clientToken?: string; } @@ -217,6 +255,9 @@ export async function startBatchEvaluation(options: StartBatchEvaluationOptions) evaluationConfig: options.evaluationConfig, sessionSource: options.sessionSource, }; + if (options.sessionMetadata && options.sessionMetadata.length > 0) { + body.sessionMetadata = options.sessionMetadata; + } if (options.executionRoleArn) { body.executionRoleArn = options.executionRoleArn; } diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx index ae2274f7c..05611ef2b 100644 --- a/src/cli/commands/config-bundle/command.tsx +++ b/src/cli/commands/config-bundle/command.tsx @@ -107,13 +107,13 @@ export const registerConfigBundle = (program: Command) => { const cmd = program .command('config-bundle') .alias('cb') - .description('View configuration bundle version history and diffs'); + .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') + .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")') @@ -198,10 +198,10 @@ export const registerConfigBundle = (program: Command) => { // --- diff --- cmd .command('diff') - .description('Diff two versions of a configuration bundle') - .requiredOption('--bundle ', 'Bundle name') - .requiredOption('--from ', 'Source version ID') - .requiredOption('--to ', 'Target version ID') + .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 }) => { diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index 37caf216c..fc72a13fc 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -170,6 +170,10 @@ export const registerRun = (program: Command) => { .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('--execution-role ', 'IAM execution role ARN for batch evaluation') .option('--json', 'Output as JSON') @@ -180,6 +184,7 @@ export const registerRun = (program: Command) => { name?: string; lookbackDays?: string; sessionIds?: string[]; + groundTruth?: string; region?: string; executionRole?: string; json?: boolean; @@ -187,6 +192,23 @@ export const registerRun = (program: Command) => { 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, @@ -196,6 +218,7 @@ export const registerRun = (program: Command) => { executionRoleArn: cliOptions.executionRole, sessionIds: cliOptions.sessionIds, lookbackDays: lookbackDays && !isNaN(lookbackDays) ? lookbackDays : undefined, + sessionMetadata, onProgress: cliOptions.json ? undefined : (_status, message) => { @@ -221,6 +244,9 @@ export const registerRun = (program: Command) => { formatBatchEvalOutput(result); } else { render({result.error}); + if (result.logFilePath) { + console.error(`\nLog: ${result.logFilePath}`); + } } process.exit(result.success ? 0 : 1); @@ -348,14 +374,24 @@ export const registerRun = (program: Command) => { 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) { - saveRecommendationRun(result.recommendationId, result, recType, agent, evaluator ? [evaluator] : []); + savedFilePath = saveRecommendationRun( + result.recommendationId, + result, + recType, + agent, + evaluator ? [evaluator] : [] + ); } } catch { // Non-fatal — skip saving @@ -387,6 +423,9 @@ export const registerRun = (program: Command) => { } } + if (savedFilePath) { + console.log(`\nResults saved to: ${savedFilePath}`); + } console.log(''); } diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 9e6de5e37..91f2637bc 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -16,6 +16,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; @@ -58,7 +60,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, 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') @@ -142,11 +144,13 @@ 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'); render( - AgentCore Status (target: {result.targetName} + AgentCore Status (target: {result.targetName || 'No target configured'} {result.targetRegion ? `, ${result.targetRegion}` : ''}) @@ -229,6 +233,24 @@ export const registerStatus = (program: Command) => { )} + {configBundles.length > 0 && ( + + Config Bundles + {configBundles.map(entry => ( + + ))} + + )} + + {abTests.length > 0 && ( + + A/B Tests + {abTests.map(entry => ( + + ))} + + )} + {filtered.length === 0 && No resources match the given filters.} ); diff --git a/src/cli/operations/config-bundle/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts index d1f3b97d9..8b0dc214f 100644 --- a/src/cli/operations/config-bundle/resolve-bundle.ts +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -30,12 +30,18 @@ export async function resolveBundleByName( const bundles = target?.resources?.configBundles; const bundle = bundles?.[bundleName]; if (bundle) { - return { - bundleId: bundle.bundleId, - bundleArn: bundle.bundleArn, - versionId: bundle.versionId, - region, - }; + // Verify the deployed-state ID is still valid (bundles may have been recreated) + try { + const verified = await getConfigurationBundle({ region, bundleId: bundle.bundleId, branchName: 'main' }); + return { + bundleId: bundle.bundleId, + bundleArn: bundle.bundleArn, + versionId: verified.versionId, + region, + }; + } catch { + // Stale deployed-state entry — fall through to API lookup + } } } @@ -47,7 +53,7 @@ export async function resolveBundleByName( } // Fetch the bundle to get the latest versionId (required by Recommendation API) - const bundle = await getConfigurationBundle({ region, bundleId: match.bundleId }); + const bundle = await getConfigurationBundle({ region, bundleId: match.bundleId, branchName: 'main' }); return { bundleId: match.bundleId, diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index 3faac7029..cdd189884 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -13,6 +13,7 @@ import type { CloudWatchSessionInput, EvaluationResults, GetBatchEvaluationResult, + SessionMetadataEntry, } from '../../aws/agentcore-batch-evaluation'; import { detectRegion } from '../../aws/region'; import { ExecLogger } from '../../logging/exec-logger'; @@ -37,6 +38,8 @@ export interface RunBatchEvaluationOptions { 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 */ @@ -91,10 +94,16 @@ export async function runBatchEvaluationCommand( // 1. Read project config and deployed state logger?.startStep('Load project config'); const configIO = new ConfigIO(); - const [projectSpec, deployedState] = await Promise.all([configIO.readProjectSpec(), configIO.readDeployedState()]); - + 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 ?? detectedRegion; + const region = options.region ?? targetRegion ?? detectedRegion; const stage = process.env.AGENTCORE_STAGE?.toLowerCase() ?? 'prod'; logger?.log(`Region: ${region}, Stage: ${stage}`); logger?.endStep('success'); @@ -121,16 +130,19 @@ export async function runBatchEvaluationCommand( 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 => { - if (name.startsWith('Builtin.')) return name; - const deployed = targetResources?.evaluators?.[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 "${name}" → ${deployed.evaluatorId}`); + logger?.log(`Resolved evaluator "${shortName}" → ${deployed.evaluatorId}`); return deployed.evaluatorId; } - logger?.log(`Evaluator "${name}" not found in deployed state, passing as-is`, 'warn'); - return name; + logger?.log(`Evaluator "${shortName}" not found in deployed state, passing as-is`, 'warn'); + return shortName; }); // 3. Start the batch evaluation @@ -141,9 +153,15 @@ export async function runBatchEvaluationCommand( // Build optional session input for CloudWatch filtering // API requires either sessionIds OR sessionFilterConfig, 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 sessionInput: CloudWatchSessionInput | undefined = (() => { - if (options.sessionIds && options.sessionIds.length > 0) { - return { sessionIds: options.sessionIds }; + if (hasSessionIds) { + return { sessionIds: effectiveSessionIds }; } if (options.lookbackDays) { const endTime = new Date().toISOString(); @@ -166,6 +184,9 @@ export async function runBatchEvaluationCommand( ...(sessionInput ? { sessionInput } : {}), }, }, + ...(options.sessionMetadata && options.sessionMetadata.length > 0 + ? { sessionMetadata: options.sessionMetadata } + : {}), ...(options.executionRoleArn ? { executionRoleArn: options.executionRoleArn } : {}), clientToken: generateClientToken(), }; 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/screens/config-bundle-hub/useConfigBundleHub.ts b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts index c642f7e2e..e886b17b5 100644 --- a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts +++ b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts @@ -3,7 +3,10 @@ * and enriches deployed ones with version metadata from the API. */ import type { ConfigurationBundleVersionSummary } from '../../../../cli/aws/agentcore-config-bundles'; -import { listConfigurationBundleVersions } from '../../../../cli/aws/agentcore-config-bundles'; +import { + listConfigurationBundleVersions, + listConfigurationBundles, +} from '../../../../cli/aws/agentcore-config-bundles'; import { ConfigIO } from '../../../../lib'; import { useEffect, useRef, useState } from 'react'; @@ -86,10 +89,14 @@ export function useConfigBundleHub(): ConfigBundleHubState { } // 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: deployed.bundleId, + bundleId: effectiveBundleId, maxResults: 50, }); const branchSet = new Set(); @@ -99,8 +106,8 @@ export function useConfigBundleHub(): ConfigBundleHubState { if (v.versionCreatedAt > latestTs) latestTs = v.versionCreatedAt; } return { - bundleId: deployed.bundleId, - bundleArn: deployed.bundleArn, + bundleId: effectiveBundleId, + bundleArn: effectiveBundleArn, bundleName: bundleSpec.name, description: bundleSpec.description, versionCount: versions.versions.length, @@ -108,9 +115,40 @@ export function useConfigBundleHub(): ConfigBundleHubState { lastUpdated: latestTs || undefined, }; } catch { + // Stale deployed-state ID — try to resolve via list API + try { + const allBundles = await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 }); + const match = allBundles.bundles.find(b => b.bundleName === bundleSpec.name); + 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: deployed.bundleId, - bundleArn: deployed.bundleArn, + bundleId: effectiveBundleId, + bundleArn: effectiveBundleArn, bundleName: bundleSpec.name, description: bundleSpec.description, versionCount: 0, diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index 475b27deb..c36b4065f 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -8,6 +8,7 @@ import type { AddConfigBundleConfig, ComponentInputMethod } from './types'; import { CONFIG_BUNDLE_STEP_LABELS, INPUT_METHOD_OPTIONS } from './types'; import { useAddConfigBundleWizard } from './useAddConfigBundleWizard'; import { existsSync, readFileSync } from 'fs'; +import { Box, Text } from 'ink'; import React, { useMemo } from 'react'; interface AddConfigBundleScreenProps { @@ -129,24 +130,36 @@ export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames )} {isComponentsStep && wizard.config.inputMethod === 'inline' && ( - { - const parsed = JSON.parse(value) as Record }>; - wizard.setComponents(parsed, value); - }} - onCancel={() => wizard.goBack()} - customValidation={validateComponentsJson} - /> + <> + + Expected format: a JSON map of component ARN → configuration + Example: + + {' '}{ "arn:aws:bedrock-agentcore:REGION:ACCOUNT:agent-runtime/RUNTIME": { + "configuration": { "key": "value" } } } + + + { + const parsed = JSON.parse(value) as Record }>; + wizard.setComponents(parsed, value); + }} + onCancel={() => wizard.goBack()} + customValidation={validateComponentsJson} + /> + )} {isComponentsStep && wizard.config.inputMethod === 'file' && ( { const raw = readFileSync(value.trim(), 'utf-8'); diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index c37982db5..d1bdc85cd 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -28,7 +28,7 @@ type FlowState = } | { name: 'results'; result: RunRecommendationCommandResult; config: RecommendationWizardConfig; filePath?: string } | { name: 'creds-error'; message: string } - | { name: 'error'; message: string }; + | { name: 'error'; message: string; logFilePath?: string }; interface RecommendationFlowProps { onExit: () => void; @@ -223,7 +223,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { }); await new Promise(resolve => setTimeout(resolve, 2000)); if (cancelled) return; - setFlow({ name: 'error', message: result.error ?? 'Recommendation failed' }); + setFlow({ name: 'error', message: result.error ?? 'Recommendation failed', logFilePath: result.logFilePath }); return; } @@ -344,7 +344,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { return ( setFlow({ name: 'loading' })} onExit={onExit} /> diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 1d815805f..2378d2917 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -1,4 +1,5 @@ import { validateAwsCredentials } from '../../../aws/account'; +import type { SessionMetadataEntry } from '../../../aws/agentcore-batch-evaluation'; import { listEvaluators } from '../../../aws/agentcore-control'; import { detectRegion } from '../../../aws/region'; import { getErrorMessage } from '../../../errors'; @@ -16,6 +17,7 @@ import { ErrorPrompt, GradientText, Panel, + PathInput, Screen, StepIndicator, StepProgress, @@ -27,8 +29,12 @@ 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 } from 'ink'; +import { readFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; // ============================================================================ @@ -37,7 +43,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' const DEFAULT_LOOKBACK_DAYS = 7; -type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'name' | 'confirm'; +type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'ground-truth' | 'name' | 'confirm'; interface BatchEvalConfig { agent: string; @@ -45,6 +51,8 @@ interface BatchEvalConfig { evaluatorNames: string[]; days: number; sessionIds: string[]; + groundTruthFile: string; + sessionMetadata?: SessionMetadataEntry[]; name: string; } @@ -53,6 +61,7 @@ const STEP_LABELS: Record = { evaluators: 'Evaluators', days: 'Lookback', sessions: 'Sessions', + 'ground-truth': 'Ground Truth', name: 'Name', confirm: 'Confirm', }; @@ -61,9 +70,9 @@ type FlowState = | { name: 'loading' } | { name: 'wizard'; agents: AgentItem[]; evaluators: EvaluatorItem[] } | { name: 'running'; config: BatchEvalConfig; steps: Step[]; elapsed: number } - | { name: 'results'; result: RunBatchEvaluationCommandResult } + | { name: 'results'; result: RunBatchEvaluationCommandResult; savedFilePath?: string } | { name: 'creds-error'; message: string } - | { name: 'error'; message: string }; + | { name: 'error'; message: string; logFilePath?: string }; // ============================================================================ // Flow Component @@ -90,8 +99,11 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { } try { - const { region } = await detectRegion(); - const [evalResult, context] = await Promise.all([listEvaluators({ region }), loadDeployedProjectConfig()]); + 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; @@ -182,6 +194,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { 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 => { @@ -200,9 +213,10 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { if (cancelled) return; // Save results locally + let savedFilePath: string | undefined; if (result.success) { try { - saveBatchEvalRun(result); + savedFilePath = saveBatchEvalRun(result); } catch { // Non-fatal } @@ -218,7 +232,11 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { }); await new Promise(resolve => setTimeout(resolve, 2000)); if (cancelled) return; - setFlow({ name: 'error', message: result.error ?? 'Batch evaluation failed' }); + setFlow({ + name: 'error', + message: result.error ?? 'Batch evaluation failed', + logFilePath: result.logFilePath, + }); return; } @@ -229,7 +247,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { return { ...prev, steps }; }); - setFlow({ name: 'results', result }); + setFlow({ name: 'results', result, savedFilePath }); } catch (err) { clearInterval(timer); if (!cancelled) { @@ -300,13 +318,20 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { } if (flow.name === 'results') { - return setFlow({ name: 'loading' })} onExit={onExit} />; + return ( + setFlow({ name: 'loading' })} + onExit={onExit} + /> + ); } return ( setFlow({ name: 'loading' })} onExit={onExit} /> @@ -329,8 +354,8 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit const allSteps = useMemo( () => skipAgent - ? ['evaluators', 'days', 'sessions', 'name', 'confirm'] - : ['agent', 'evaluators', 'days', 'sessions', 'name', 'confirm'], + ? ['evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm'] + : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm'], [skipAgent] ); @@ -341,15 +366,21 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit 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) setStep(prev); - else onExit(); + if (prev) { + if (prev === 'ground-truth') setGtMode('choose'); + setStep(prev); + } else onExit(); }, [allSteps, currentIndex, onExit]); const goNext = useCallback(() => { @@ -383,6 +414,7 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit 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'; @@ -399,7 +431,9 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit void (async () => { try { const context = await loadDeployedProjectConfig(); - const { region } = await detectRegion(); + 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 }); @@ -507,6 +541,32 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit 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), @@ -526,14 +586,20 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit : sessionPhase === 'error' ? HELP_TEXT.CONFIRM_CANCEL : 'Space toggle · Enter confirm · Esc back' - : isNameStep - ? HELP_TEXT.TEXT_INPUT - : HELP_TEXT.CONFIRM_CANCEL; + : 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 && ( )} + {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. @@ -621,6 +765,9 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit label: 'Sessions', value: `${config.sessionIds.length} selected`, }, + ...(config.sessionMetadata + ? [{ label: 'Ground Truth', value: `${config.sessionMetadata.length} session(s) with ground truth` }] + : []), ...(config.name ? [{ label: 'Name', value: config.name }] : []), ]} /> @@ -642,11 +789,12 @@ function scoreColor(score: number): string { interface ResultsViewProps { result: RunBatchEvaluationCommandResult; + savedFilePath?: string; onRunAnother: () => void; onExit: () => void; } -function ResultsView({ result, onRunAnother, onExit }: ResultsViewProps) { +function ResultsView({ result, savedFilePath, onRunAnother, onExit }: ResultsViewProps) { const actions = [ { id: 'another', title: 'Run another batch evaluation' }, { id: 'back', title: 'Back' }, @@ -744,6 +892,11 @@ function ResultsView({ result, onRunAnother, onExit }: ResultsViewProps) { )} + {savedFilePath && ( + + Results saved to: {savedFilePath} + + )} {result.logFilePath && ( Log: {result.logFilePath} From b13933b42ad0b30b23b31aca6e301d42338dfbef Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:16:36 -0400 Subject: [PATCH 26/64] feat: AB test HTTP gateway, trace delivery, and TUI improvements (#63) --- package-lock.json | 18 +- scripts/bundle.mjs | 7 +- src/cli/aws/agentcore-http-gateways.ts | 462 ++++++++++++++ .../cloudformation/__tests__/outputs.test.ts | 114 ++++ src/cli/cloudformation/outputs.ts | 12 + src/cli/commands/abtest/command.ts | 16 +- src/cli/commands/deploy/actions.ts | 149 ++++- .../commands/logs/__tests__/action.test.ts | 4 + src/cli/commands/remove/command.tsx | 1 + src/cli/commands/status/action.ts | 37 ++ src/cli/commands/status/command.tsx | 14 +- .../__tests__/checks-extended.test.ts | 10 + .../agent/generate/write-agent-to-project.ts | 1 + .../__tests__/post-deploy-ab-tests.test.ts | 49 +- .../post-deploy-http-gateways.test.ts | 440 ++++++++++++++ .../post-deploy-online-evals.test.ts | 179 ++++++ src/cli/operations/deploy/index.ts | 16 + .../operations/deploy/post-deploy-ab-tests.ts | 163 +++-- .../deploy/post-deploy-http-gateways.ts | 574 ++++++++++++++++++ .../deploy/post-deploy-online-evals.ts | 80 +++ src/cli/operations/deploy/teardown.ts | 69 +++ .../operations/dev/__tests__/config.test.ts | 21 + .../operations/fetch-access/list-gateways.ts | 12 + src/cli/primitives/ABTestPrimitive.ts | 94 ++- .../__tests__/ABTestPrimitive.test.ts | 37 +- .../__tests__/GatewayPrimitive.test.ts | 1 + .../primitives/__tests__/auth-utils.test.ts | 1 + src/cli/project.ts | 1 + src/cli/tui/components/ResourceGraph.tsx | 3 +- src/cli/tui/constants.ts | 2 + src/cli/tui/hooks/useCreateABTest.ts | 7 +- .../screens/ab-test/ABTestDetailScreen.tsx | 256 +++++++- src/cli/tui/screens/ab-test/AddABTestFlow.tsx | 15 +- .../tui/screens/ab-test/AddABTestScreen.tsx | 160 +++-- .../tui/screens/ab-test/VariantConfigForm.tsx | 1 + .../__tests__/useAddABTestWizard.test.tsx | 96 ++- src/cli/tui/screens/ab-test/types.ts | 7 +- .../tui/screens/ab-test/useAddABTestWizard.ts | 47 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 116 +++- .../screens/online-eval/AddOnlineEvalFlow.tsx | 4 +- src/schema/llm-compacted/agentcore.ts | 309 +++++++++- src/schema/schemas/agentcore-project.ts | 40 ++ src/schema/schemas/deployed-state.ts | 16 + .../primitives/__tests__/ab-test.test.ts | 2 +- .../primitives/__tests__/http-gateway.test.ts | 82 +++ src/schema/schemas/primitives/ab-test.ts | 2 +- src/schema/schemas/primitives/http-gateway.ts | 29 + src/schema/schemas/primitives/index.ts | 3 + 48 files changed, 3603 insertions(+), 176 deletions(-) create mode 100644 src/cli/aws/agentcore-http-gateways.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-http-gateways.ts create mode 100644 src/cli/operations/deploy/post-deploy-online-evals.ts create mode 100644 src/schema/schemas/primitives/__tests__/http-gateway.test.ts create mode 100644 src/schema/schemas/primitives/http-gateway.ts diff --git a/package-lock.json b/package-lock.json index 10ab5bb22..840dcab29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.8.0", + "version": "0.8.0-evo", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.8.0", + "version": "0.8.0-evo", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4869,9 +4869,9 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.0.tgz", - "integrity": "sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.1.tgz", + "integrity": "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.23.14", @@ -4881,7 +4881,7 @@ "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@smithy/util-retry": "^4.3.1", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -5224,9 +5224,9 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.0.tgz", - "integrity": "sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.1.tgz", + "integrity": "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==", "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.13", 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/src/cli/aws/agentcore-http-gateways.ts b/src/cli/aws/agentcore-http-gateways.ts new file mode 100644 index 000000000..8ace1a4e4 --- /dev/null +++ b/src/cli/aws/agentcore-http-gateways.ts @@ -0,0 +1,462 @@ +/** + * 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 { 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; +} + +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}.amazonaws.com`; +} + +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: { + runtimeTargetConfiguration: { + arn: options.runtimeArn, + 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) { + throw new Error( + `Failed to create target "${options.targetName}" in gateway ${options.gatewayId}: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +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 ListHttpGatewayTargetsResult; + return { + targets: result.targets ?? [], + }; +} + +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}`, + }); + return { success: true }; + } 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/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 48aca6cb1..8940d0c1f 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -410,6 +410,18 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta 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 index f591e0a98..e828589b6 100644 --- a/src/cli/commands/abtest/command.ts +++ b/src/cli/commands/abtest/command.ts @@ -47,12 +47,23 @@ async function resolveABTestId(testName: string, region: string): Promise<{ abTe return { abTestId: '', 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}.amazonaws.com`; + } + 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(` Gateway: ${test.gatewayArn}`); + lines.push(` Gateway URL: ${gatewayUrlFromArn(test.gatewayArn)}`); + lines.push(` Online Eval: ${test.evaluationConfig.onlineEvaluationConfigArn}`); if (test.description) lines.push(` Description: ${test.description}`); for (const variant of test.variants) { @@ -62,7 +73,8 @@ function formatABTestDetails(test: GetABTestResult): string { ); } - if (test.maxDurationDays) lines.push(` Max Duration: ${test.maxDurationDays} days`); + // 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}`); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 32a37489a..1f8129054 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,5 +1,5 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; -import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; +import type { AgentCoreMcpSpec, AgentCoreProjectSpec, DeployedState } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; import { @@ -31,8 +31,10 @@ import { validateProject, } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; -import { setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; +import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; import { 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 { @@ -411,7 +413,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise undefined); - const deployedState = buildDeployedState({ + let deployedState = buildDeployedState({ targetName: target.name, stackName, agents, @@ -445,13 +447,97 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise !previouslyDeployedOnlineEvals[c.name]); + if (newOnlineEvalSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { + const enableResult = await enableOnlineEvalConfigs({ + region: target.region, + onlineEvalConfigs: newOnlineEvalSpecs, + 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'); + } + } + + // 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'); + } + + // 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('; '); + throw new Error(`HTTP gateway setup failed: ${errorMessages}`); + } + } + // 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: context.projectSpec, + projectSpec: resolvedProjectSpec, existingBundles: existingConfigBundles, }); @@ -462,6 +548,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { + 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; + } + + const rtMatch = /^\{\{runtime:(.+)\}\}$/.exec(key); + if (rtMatch) { + const rtName = rtMatch[1]!; + const rt = resources.runtimes?.[rtName]; + if (rt) return rt.runtimeArn; + } + + return key; +} diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index ca587fd84..07d072320 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -62,6 +62,7 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }, deployedState: { targets: { @@ -125,6 +126,7 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }, }); const result = resolveAgentContext(context, {}); @@ -168,6 +170,7 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }, deployedState: { targets: { @@ -221,6 +224,7 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index ae8815e83..e3601da47 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -36,6 +36,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise({ 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}.amazonaws.com` : 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 @@ -220,6 +242,21 @@ export function computeResourceStatuses( 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; + } + return [ ...agents, ...credentials, diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 91f2637bc..a306888bf 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -146,6 +146,7 @@ export const registerStatus = (program: Command) => { 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( @@ -244,13 +245,22 @@ export const registerStatus = (program: Command) => { {abTests.length > 0 && ( - A/B Tests + AB Tests {abTests.map(entry => ( - + + + {entry.invocationUrl && ( + + {' '}Gateway 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/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts index e42541156..462d9be14 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -55,6 +55,7 @@ describe('requiresUv', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(requiresUv(project)).toBe(true); }); @@ -82,6 +83,7 @@ describe('requiresUv', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(requiresUv(project)).toBe(false); }); @@ -100,6 +102,7 @@ describe('requiresUv', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(requiresUv(project)).toBe(false); }); @@ -129,6 +132,7 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -156,6 +160,7 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -174,6 +179,7 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -209,6 +215,7 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -278,6 +285,7 @@ describe('checkDependencyVersions', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const result = await checkDependencyVersions(project); @@ -300,6 +308,7 @@ describe('checkDependencyVersions', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const result = await checkDependencyVersions(project); @@ -330,6 +339,7 @@ describe('checkDependencyVersions', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const result = await checkDependencyVersions(project); 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 8c0d30944..bf48ddf9d 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -74,6 +74,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; await configIO.writeProjectSpec(project); 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 9089fb161..780f15ad0 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 @@ -1,5 +1,5 @@ import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema'; -import { setupABTests } from '../post-deploy-ab-tests.js'; +import { deleteOrphanedABTests, setupABTests } from '../post-deploy-ab-tests.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────────── @@ -57,13 +57,14 @@ function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCo agentCoreGateways: [], policyEngines: [], configBundles: [], + httpGateways: [], abTests, }; } const sampleABTest = { name: 'TestOne', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', + gatewayRef: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', variants: [ { name: 'C' as const, @@ -214,7 +215,7 @@ describe('setupABTests', () => { it('resolves gateway placeholder to ARN', async () => { const testWithPlaceholder = { ...sampleABTest, - gatewayArn: '{{gateway:my-gw}}', + gatewayRef: '{{gateway:my-gw}}', }; mockCreateABTest.mockResolvedValue({ abTestId: 'abt-005', abTestArn: 'arn:abt:005' }); @@ -235,6 +236,31 @@ describe('setupABTests', () => { ); }); + 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, @@ -260,7 +286,7 @@ describe('setupABTests', () => { it('deletes orphaned AB test not in project spec', async () => { mockDeleteABTest.mockResolvedValue({ success: true }); - const result = await setupABTests({ + const result = await deleteOrphanedABTests({ region: 'us-east-1', projectSpec: makeProjectSpec([]), existingABTests: { @@ -276,7 +302,7 @@ describe('setupABTests', () => { mockDeleteABTest.mockResolvedValue({ success: true }); mockIAMSend.mockResolvedValue({}); - await setupABTests({ + await deleteOrphanedABTests({ region: 'us-east-1', projectSpec: makeProjectSpec([]), existingABTests: { @@ -306,7 +332,7 @@ describe('setupABTests', () => { it('does not delete role when roleCreatedByCli is false', async () => { mockDeleteABTest.mockResolvedValue({ success: true }); - await setupABTests({ + await deleteOrphanedABTests({ region: 'us-east-1', projectSpec: makeProjectSpec([]), existingABTests: { @@ -325,7 +351,7 @@ describe('setupABTests', () => { it('reports error when deletion fails', async () => { mockDeleteABTest.mockRejectedValue(new Error('delete failed')); - const result = await setupABTests({ + const result = await deleteOrphanedABTests({ region: 'us-east-1', projectSpec: makeProjectSpec([]), existingABTests: { @@ -392,7 +418,7 @@ describe('setupABTests', () => { mockDeleteABTest.mockResolvedValue({ success: true }); mockIAMSend.mockRejectedValue(new Error('IAM permission denied')); - const result = await setupABTests({ + const result = await deleteOrphanedABTests({ region: 'us-east-1', projectSpec: makeProjectSpec([]), existingABTests: { @@ -411,27 +437,24 @@ describe('setupABTests', () => { }); describe('mixed operations', () => { - it('creates new, skips existing, and deletes orphaned in one call', async () => { + it('creates new and skips existing', async () => { const newTest = { ...sampleABTest, name: 'NewTest' }; const keptTest = { ...sampleABTest, name: 'KeptTest' }; mockCreateABTest.mockResolvedValue({ abTestId: 'abt-new', abTestArn: 'arn:abt:new' }); - mockDeleteABTest.mockResolvedValue({ success: true }); const result = await setupABTests({ region: 'us-east-1', projectSpec: makeProjectSpec([newTest, keptTest]), existingABTests: { KeptTest: { abTestId: 'abt-kept', abTestArn: 'arn:abt:kept' }, - OrphanTest: { abTestId: 'abt-orphan', abTestArn: 'arn:abt:orphan' }, }, }); - expect(result.results).toHaveLength(3); + expect(result.results).toHaveLength(2); const statuses = result.results.map(r => `${r.testName}:${r.status}`); expect(statuses).toContain('NewTest:created'); expect(statuses).toContain('KeptTest:skipped'); - expect(statuses).toContain('OrphanTest:deleted'); }); }); }); 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..a0b295451 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts @@ -0,0 +1,440 @@ +import type { AgentCoreProjectSpec, DeployedResourceState, HttpGatewayDeployedState } from '../../../../schema'; +import { 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, + mockCWLogsSend, +} = 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(), + mockCWLogsSend: 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-sdk/client-cloudwatch-logs', () => ({ + CloudWatchLogsClient: class { + send = mockCWLogsSend; + }, + PutDeliverySourceCommand: class { + constructor(public input: unknown) {} + }, + PutDeliveryDestinationCommand: class { + constructor(public input: unknown) {} + }, + CreateDeliveryCommand: class { + constructor(public input: unknown) {} + }, +})); + +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({}); + mockCWLogsSend.mockResolvedValue({ deliveryDestination: { arn: 'arn:aws:logs:us-east-1:123:delivery-dest/test' } }); + }); + + 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: 'MyHttpGw', + roleArn: 'arn:aws:iam::123456789012:role/ExistingRole', + }); + expect(mockCreateHttpGatewayTarget).toHaveBeenCalledWith({ + region: 'us-east-1', + gatewayId: 'gw-001', + targetName: '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: '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('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 setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + 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 setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + 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 setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + 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, skips existing, and deletes orphaned in one call', 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(3); + const statuses = result.results.map(r => `${r.gatewayName}:${r.status}`); + expect(statuses).toContain('NewGw:created'); + expect(statuses).toContain('KeptGw:skipped'); + expect(statuses).toContain('OrphanGw:deleted'); + }); + }); +}); 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/index.ts b/src/cli/operations/deploy/index.ts index a420e7a05..332f0ca2d 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -45,6 +45,22 @@ 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, diff --git a/src/cli/operations/deploy/post-deploy-ab-tests.ts b/src/cli/operations/deploy/post-deploy-ab-tests.ts index e3733b929..7c205c36e 100644 --- a/src/cli/operations/deploy/post-deploy-ab-tests.ts +++ b/src/cli/operations/deploy/post-deploy-ab-tests.ts @@ -6,10 +6,11 @@ import { CreateRoleCommand, DeleteRoleCommand, DeleteRolePolicyCommand, + GetRoleCommand, IAMClient, PutRolePolicyCommand, } from '@aws-sdk/client-iam'; -import { randomBytes } from 'node:crypto'; +import { createHash } from 'node:crypto'; // ============================================================================ // Types @@ -60,8 +61,6 @@ export async function setupABTests(options: SetupABTestsOptions): Promise = {}; - const specTestNames = new Set(projectSpec.abTests.map(t => t.name)); - // Create or skip tests from the spec for (const testSpec of projectSpec.abTests) { try { @@ -97,7 +96,15 @@ export async function setupABTests(options: SetupABTestsOptions): Promise 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 { + 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, + }); + } catch (err) { + results.push({ + testName, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); } } } return { results, - abTests, hasErrors: results.some(r => r.status === 'error'), }; } @@ -273,6 +305,12 @@ function resolveGatewayArn(ref: string, deployedResources?: DeployedResourceStat 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; } @@ -302,11 +340,11 @@ function resolveEvalConfig( * AgentCore-{ProjectName}-ABTest{TestName}-{Hash} */ function generateRoleName(projectName: string, testName: string): string { - const hash = randomBytes(6).toString('base64url').slice(0, 8); + // 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 - const truncated = base.slice(0, 55); - return `${truncated}-${hash}`; + return `${base.slice(0, 55)}-${hash}`; } /** @@ -348,18 +386,42 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< ], }); - 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 }, - ], - }) - ); + 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') { + console.log(`IAM role "${roleName}" already exists — reusing 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', @@ -429,6 +491,7 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< ], }); + // Re-apply the inline policy (idempotent — covers both new and recovered roles) await iamClient.send( new PutRolePolicyCommand({ RoleName: roleName, @@ -437,10 +500,12 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< }) ); - // Wait for IAM role propagation before returning - await new Promise(resolve => setTimeout(resolve, 15_000)); + if (needsPropagationWait) { + console.log('Waiting for IAM role propagation (~15s)...'); + await new Promise(resolve => setTimeout(resolve, 15_000)); + } - return createResult.Role!.Arn!; + return roleArn; } async function deleteABTestRole(region: string, roleArn: string): Promise { 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..1cb317e45 --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-http-gateways.ts @@ -0,0 +1,574 @@ +import type { AgentCoreProjectSpec, DeployedResourceState, HttpGatewayDeployedState } from '../../../schema'; +import { getCredentialProvider } from '../../aws/account'; +import { + createHttpGateway, + createHttpGatewayTarget, + deleteHttpGateway, + deleteHttpGatewayTarget, + listAllHttpGateways, + listHttpGatewayTargets, + waitForGatewayReady, + waitForTargetReady, +} from '../../aws/agentcore-http-gateways'; +import { + CloudWatchLogsClient, + CreateDeliveryCommand, + PutDeliveryDestinationCommand, + PutDeliverySourceCommand, +} from '@aws-sdk/client-cloudwatch-logs'; +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 ?? []; + + const specGatewayNames = new Set(httpGatewaySpecs.map(gw => gw.name)); + + // 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 -- skip + // Gateway already deployed — skip silently + 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 existingByName = await findHttpGatewayByName(region, gwSpec.name); + 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; + } + + // 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: 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: 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 the gateway if target creation/readiness fails + 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; + } + + // Enable gateway trace delivery to aws/spans (required for online eval + AB test aggregation). + // Without this, the AB test aggregation pipeline won't receive gateway spans. + try { + await enableGatewayTraceDelivery({ + region, + gatewayName: gwSpec.name, + gatewayArn: createResult.gatewayArn, + }); + } catch (traceErr) { + // Rollback: delete target, gateway, and role + 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 cleanup + } + if (roleCreatedByCli && resolvedRoleArn) { + try { + await deleteHttpGatewayRole(region, resolvedRoleArn); + } catch { + // Best-effort role cleanup + } + } + + results.push({ + gatewayName: gwSpec.name, + status: 'error', + error: + `Trace delivery failed, gateway rolled back: ${traceErr instanceof Error ? traceErr.message : String(traceErr)}. ` + + `Enable manually with: aws logs put-delivery-source --name gateway-traces-${gwSpec.name} --resource-arn ${createResult.gatewayArn} --log-type TRACES --region ${region}`, + }); + 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), + }); + } + } + + // Delete orphaned HTTP gateways (in deployed-state but removed from spec) + if (existingHttpGateways) { + for (const [gwName, gwState] of Object.entries(existingHttpGateways)) { + if (!specGatewayNames.has(gwName)) { + try { + // Delete all targets before deleting the gateway. + // Use known targetId first; fall back to listing all targets. + const targetIds: string[] = []; + if (gwState.targetId) { + targetIds.push(gwState.targetId); + } else { + try { + const targets = await listHttpGatewayTargets({ + region, + gatewayId: gwState.gatewayId, + maxResults: 100, + }); + targetIds.push(...targets.targets.map(t => t.targetId)); + } catch { + // Best-effort — proceed with gateway deletion anyway + } + } + + for (const targetId of targetIds) { + const targetDeleteResult = await deleteHttpGatewayTarget({ + region, + gatewayId: gwState.gatewayId, + targetId, + }); + if (!targetDeleteResult.success) { + console.warn( + `Warning: Failed to delete target "${targetId}" for orphaned gateway "${gwName}": ${targetDeleteResult.error}. Proceeding with best-effort gateway deletion.` + ); + } + } + + // Delete gateway (best-effort even if target deletion failed) + const deleteResult = await deleteHttpGateway({ + region, + gatewayId: gwState.gatewayId, + }); + + // Clean up the auto-created IAM role only if gateway deletion succeeded + if (deleteResult.success && gwState.roleCreatedByCli && gwState.roleArn) { + await deleteHttpGatewayRole(region, gwState.roleArn); + } + + results.push({ + gatewayName: gwName, + status: deleteResult.success ? 'deleted' : 'error', + error: deleteResult.error, + }); + } catch (err) { + results.push({ + gatewayName: gwName, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + } + + return { + results, + httpGateways, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +// ============================================================================ +// Gateway Trace Delivery +// ============================================================================ + +/** + * Enable CloudWatch log delivery for gateway traces. + * + * Sets up the full delivery chain: source → destination → delivery. + * Required for online eval + AB test aggregation pipeline. + * + * 1. PutDeliverySource — register gateway as TRACES source + * 2. PutDeliveryDestination — create XRAY destination + * 3. CreateDelivery — connect source to destination + */ +async function enableGatewayTraceDelivery(options: { + region: string; + gatewayName: string; + gatewayArn: string; +}): Promise { + const { region, gatewayName, gatewayArn } = options; + const credentials = getCredentialProvider(); + const logsClient = new CloudWatchLogsClient({ region, credentials }); + + const sourceName = `agentcore-gw-traces-${gatewayName}`; + const destName = `agentcore-gw-dest-${gatewayName}`; + + // 1. Register gateway as trace source + await logsClient.send( + new PutDeliverySourceCommand({ + name: sourceName, + resourceArn: gatewayArn, + logType: 'TRACES', + }) + ); + + // 2. Create XRAY destination + const destResult = await logsClient.send( + new PutDeliveryDestinationCommand({ + name: destName, + deliveryDestinationType: 'XRAY', + }) + ); + + const destArn = destResult.deliveryDestination?.arn; + if (!destArn) { + throw new Error('PutDeliveryDestination returned no ARN'); + } + + // 3. Connect source to destination (may already exist on redeploy) + try { + await logsClient.send( + new CreateDeliveryCommand({ + deliverySourceName: sourceName, + deliveryDestinationArn: destArn, + }) + ); + } catch (err) { + const errName = (err as { name?: string }).name; + if (errName !== 'ConflictException') throw err; + // Delivery already exists — idempotent + } + + // Gateway trace delivery enabled +} + +// ============================================================================ +// 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-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/teardown.ts b/src/cli/operations/deploy/teardown.ts index ed57a5cfe..ddb7de5dc 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -1,7 +1,9 @@ import { CONFIG_DIR, ConfigIO } from '../../../lib'; import type { AwsDeploymentTarget } from '../../../schema'; +import { deleteHttpGateway, deleteHttpGatewayTarget } from '../../aws/agentcore-http-gateways'; import { CdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib'; import { type DiscoveredStack, findStack } from '../../cloudformation/stack-discovery'; +import { deleteHttpGatewayRole } from './post-deploy-http-gateways'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -104,6 +106,73 @@ export async function performStackTeardown(targetName: string): Promise dt.target.name === targetName); + + // Clean up imperatively-created HTTP gateways before stack destruction + try { + const deployedState = await configIO.readDeployedState(); + const httpGateways = deployedState.targets?.[targetName]?.resources?.httpGateways; + if (httpGateways) { + 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 HTTP gateway cleanup — gateways may need manual deletion' + ); + } + if (region) { + for (const [gwName, gwState] of Object.entries(httpGateways)) { + try { + if (gwState.targetId) { + const targetResult = await deleteHttpGatewayTarget({ + region, + gatewayId: gwState.gatewayId, + targetId: gwState.targetId, + }); + if (!targetResult.success) { + console.warn( + `Warning: Failed to delete target for HTTP gateway "${gwName}": ${targetResult.error}. ` + + `Skipping gateway deletion — manual cleanup may be required.` + ); + continue; + } + } + const gwResult = await deleteHttpGateway({ region, gatewayId: gwState.gatewayId }); + if (!gwResult.success) { + console.warn(`Warning: Failed to delete HTTP gateway "${gwName}": ${gwResult.error}`); + } else { + console.log(`Deleted HTTP gateway "${gwName}"`); + } + if (gwResult.success && gwState.roleCreatedByCli && gwState.roleArn) { + try { + await deleteHttpGatewayRole(region, gwState.roleArn); + } catch { + // Best-effort role cleanup + } + } + } catch (err) { + console.warn( + `Warning: Error during HTTP gateway "${gwName}" 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 HTTP gateway 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 22423266f..844ab437c 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -23,6 +23,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project); @@ -52,6 +53,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project); @@ -81,6 +83,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -116,6 +119,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -146,6 +150,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -174,6 +179,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -205,6 +211,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; // No configRoot provided @@ -236,6 +243,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -267,6 +275,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -297,6 +306,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -327,6 +337,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -357,6 +368,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -387,6 +399,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -418,6 +431,7 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -462,6 +476,7 @@ describe('getAgentPort', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -482,6 +497,7 @@ describe('getAgentPort', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -507,6 +523,7 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -535,6 +552,7 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -571,6 +589,7 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const supported = getDevSupportedAgents(project); @@ -601,6 +620,7 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const supported = getDevSupportedAgents(project); @@ -639,6 +659,7 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const supported = getDevSupportedAgents(project); 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/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts index afda7e92a..00a50984f 100644 --- a/src/cli/primitives/ABTestPrimitive.ts +++ b/src/cli/primitives/ABTestPrimitive.ts @@ -7,10 +7,13 @@ 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; - gatewayArn: string; + agent: string; + gatewayChoice?: GatewayChoice; roleArn?: string; controlBundle: string; controlVersion: string; @@ -58,7 +61,27 @@ export class ABTestPrimitive extends BasePrimitive { + const m = /^\{\{gateway:(.+)\}\}$/.exec(t.gatewayRef); + return m && m[1] === gwName; + }); + if (!stillReferenced) { + const gwIndex = project.httpGateways.findIndex(gw => gw.name === gwName); + if (gwIndex !== -1) { + project.httpGateways.splice(gwIndex, 1); + } + } + } + } + await this.writeProjectSpec(project); return { success: true }; @@ -78,11 +101,31 @@ export class ABTestPrimitive extends BasePrimitive 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, @@ -116,7 +159,7 @@ export class ABTestPrimitive extends BasePrimitive', 'AB test name') .option('--description ', 'AB test description') - .option('--gateway-arn ', 'Gateway ARN') + .option('--agent ', 'Agent to A/B test') .option('--role-arn ', 'IAM role ARN for the AB test (auto-created if not provided)') .option('--control-bundle ', 'Control variant config bundle name or ARN') .option('--control-version ', 'Control variant config bundle version') @@ -124,16 +167,19 @@ export class ABTestPrimitive extends BasePrimitive', 'Treatment variant config bundle version') .option('--control-weight ', 'Traffic weight for control (1-100)', parseInt) .option('--treatment-weight ', 'Traffic weight for treatment (1-100)', parseInt) + .option('--gateway ', 'Use an existing HTTP gateway (skips auto-creation and --agent)') .option('--online-eval ', 'Online evaluation config name or ARN') .option('--traffic-header ', 'Header name for traffic routing') - .option('--max-duration ', 'Maximum duration in days (1-90)', parseInt) + // 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') .action( async (cliOptions: { name?: string; description?: string; - gatewayArn?: string; + agent?: string; + gateway?: string; roleArn?: string; controlBundle?: string; controlVersion?: string; @@ -164,7 +210,7 @@ export class ABTestPrimitive extends BasePrimitive 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.push({ + name: httpGwName, + runtimeRef: options.agent, + }); + } + gatewayRef = `{{gateway:${httpGwName}}}`; + } + const abTest: ABTest = { name: options.name, ...(options.description && { description: options.description }), - gatewayArn: options.gatewayArn, + gatewayRef, ...(options.roleArn && { roleArn: options.roleArn }), variants: [ { diff --git a/src/cli/primitives/__tests__/ABTestPrimitive.test.ts b/src/cli/primitives/__tests__/ABTestPrimitive.test.ts index 103a1d721..766063ff8 100644 --- a/src/cli/primitives/__tests__/ABTestPrimitive.test.ts +++ b/src/cli/primitives/__tests__/ABTestPrimitive.test.ts @@ -13,7 +13,7 @@ vi.mock('../../../lib/index.js', () => ({ findConfigRoot: () => '/fake/root', })); -function makeProject(abTests: { name: string }[] = []) { +function makeProject(abTests: { name: string; gatewayRef?: string }[] = []) { return { name: 'TestProject', version: 1, @@ -27,12 +27,13 @@ function makeProject(abTests: { name: string }[] = []) { policyEngines: [], configBundles: [], abTests, + httpGateways: [] as { name: string; runtimeRef: string }[], }; } const validOptions: AddABTestOptions = { name: 'MyTest', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-abc', + agent: 'my-agent', controlBundle: 'arn:bundle:control', controlVersion: 'v1', treatmentBundle: 'arn:bundle:treatment', @@ -189,6 +190,38 @@ describe('ABTestPrimitive', () => { 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); + expect(writtenSpec.httpGateways).toHaveLength(0); + }); + + 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', () => { diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index edac406d0..5f136eabe 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -15,6 +15,7 @@ const defaultProject: AgentCoreProjectSpec = { 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 171ad6b6f..5f0e1a7c9 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -95,6 +95,7 @@ describe('createManagedOAuthCredential', () => { policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/project.ts b/src/cli/project.ts index 13b02c798..14ea7be3c 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -20,6 +20,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS policyEngines: [], configBundles: [], abTests: [], + httpGateways: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 8cc83fa6b..11543aa20 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -104,7 +104,7 @@ function ResourceRow({ )} {invocationUrl && ( - {' '}URL: {invocationUrl} + {' '}Gateway URL: {invocationUrl} )} @@ -328,6 +328,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res detail={rsEntry?.detail ?? test.description} deploymentState={rsEntry?.deploymentState} identifier={rsEntry?.identifier} + invocationUrl={rsEntry?.invocationUrl} /> ); })} 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/hooks/useCreateABTest.ts b/src/cli/tui/hooks/useCreateABTest.ts index 2415bee36..22dbf13e0 100644 --- a/src/cli/tui/hooks/useCreateABTest.ts +++ b/src/cli/tui/hooks/useCreateABTest.ts @@ -1,10 +1,12 @@ 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; - gateway: string; + agent: string; + gatewayChoice?: GatewayChoice; controlBundle: string; controlVersion: string; treatmentBundle: string; @@ -27,7 +29,8 @@ export function useCreateABTest() { const addResult = await abTestPrimitive.add({ name: config.name, description: config.description, - gatewayArn: config.gateway, + agent: config.agent, + gatewayChoice: config.gatewayChoice, controlBundle: config.controlBundle, controlVersion: config.controlVersion, treatmentBundle: config.treatmentBundle, diff --git a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx index 7d052163c..00cddd0b9 100644 --- a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx +++ b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx @@ -1,7 +1,16 @@ +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 } from '../../../aws/agentcore-http-gateways'; import { getErrorMessage } from '../../../errors'; -import { Screen } from '../../components'; +import { GradientText, Screen } from '../../components'; +import { + CloudWatchLogsClient, + DescribeDeliveriesCommand, + DescribeDeliverySourcesCommand, + FilterLogEventsCommand, +} from '@aws-sdk/client-cloudwatch-logs'; import { Box, Text, useInput } from 'ink'; import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,6 +20,17 @@ interface ABTestDetailScreenProps { 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}.amazonaws.com`; + } + return arn; +} + /** Extract the resource ID from an ARN (last segment after / or :). */ function extractId(arn: string): string { const slashIdx = arn.lastIndexOf('/'); @@ -45,11 +65,193 @@ function rule(left?: string, right?: string, width = 48): string { 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 + const evalConfigArn = test.evaluationConfig.onlineEvaluationConfigArn; + const evalConfigId = extractId(evalConfigArn); + try { + const evalConfig = await getOnlineEvaluationConfig({ region, configId: evalConfigId }); + results.push({ + label: 'Online Eval Config', + status: evalConfig.executionStatus === 'ENABLED' ? 'pass' : 'fail', + detail: `${evalConfig.configName} — ${evalConfig.executionStatus}`, + }); + } catch (err) { + results.push({ label: 'Online Eval Config', 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) }); + } + + // 3. Gateway Trace Delivery (source + destination + delivery) + try { + const [sources, deliveries] = await Promise.all([ + logsClient.send(new DescribeDeliverySourcesCommand({})), + logsClient.send(new DescribeDeliveriesCommand({})), + ]); + + const source = (sources.deliverySources ?? []).find( + s => s.resourceArns?.some(a => a.includes(gatewayId)) && s.logType === 'TRACES' + ); + const delivery = source ? (deliveries.deliveries ?? []).find(d => d.deliverySourceName === source.name) : undefined; + + const hasSource = !!source; + const hasDelivery = !!delivery; + + if (hasSource && hasDelivery) { + results.push({ + label: 'Gateway Trace Delivery', + status: 'pass', + detail: `Source: ${source.name} → Delivery: ${delivery.id}`, + }); + } else if (hasSource) { + results.push({ + label: 'Gateway Trace Delivery', + status: 'fail', + detail: `Source exists (${source.name}) but no delivery/destination — traces not flowing`, + }); + } else { + results.push({ + label: 'Gateway Trace Delivery', + status: 'fail', + detail: 'Not enabled — gateway spans will not flow to aws/spans', + }); + } + } catch (err) { + results.push({ label: 'Gateway Trace Delivery', status: 'fail', detail: getErrorMessage(err) }); + } + + // 4. Gateway Spans in aws/spans + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + try { + const spanEvents = await logsClient.send( + new FilterLogEventsCommand({ + logGroupName: 'aws/spans', + startTime: fiveMinAgo, + filterPattern: `"${gatewayId}"`, + limit: 50, + }) + ); + const count = spanEvents.events?.length ?? 0; + results.push({ + label: 'Gateway Spans (last 5m)', + status: count > 0 ? 'pass' : 'warn', + detail: count > 0 ? `${count} spans found` : 'No recent spans — send traffic through the gateway', + }); + } catch (err) { + results.push({ label: 'Gateway Spans', status: 'fail', detail: getErrorMessage(err) }); + } + + // 5. Eval Results with variant breakdown + try { + const evalLogGroup = `/aws/bedrock-agentcore/evaluations/results/${evalConfigId}`; + const thirtyMinAgo = Date.now() - 30 * 60 * 1000; + + const [allEvents, controlEvents, treatmentEvents] = await Promise.all([ + logsClient.send(new FilterLogEventsCommand({ logGroupName: evalLogGroup, startTime: thirtyMinAgo, limit: 1 })), + logsClient.send( + new FilterLogEventsCommand({ + logGroupName: evalLogGroup, + startTime: thirtyMinAgo, + filterPattern: `"experiment.treatment_name" "C" "${test.abTestArn}"`, + limit: 100, + }) + ), + logsClient.send( + new FilterLogEventsCommand({ + logGroupName: evalLogGroup, + startTime: thirtyMinAgo, + filterPattern: `"experiment.treatment_name" "T1" "${test.abTestArn}"`, + limit: 100, + }) + ), + ]); + + const hasResults = (allEvents.events?.length ?? 0) > 0; + const controlCount = controlEvents.events?.length ?? 0; + const treatmentCount = treatmentEvents.events?.length ?? 0; + + if (!hasResults) { + results.push({ + label: 'Eval Results (last 30m)', + status: 'warn', + detail: 'No eval results yet — wait ~5m after session timeout for evaluator to process', + }); + } else { + const tagged = controlCount + treatmentCount; + results.push({ + label: 'Eval Results (last 30m)', + status: tagged > 0 ? 'pass' : 'warn', + detail: + tagged > 0 + ? `C: ${controlCount}, T1: ${treatmentCount}` + : 'Results exist but none tagged with variant — check gateway trace delivery', + }); + } + } catch (err) { + const msg = getErrorMessage(err); + results.push({ + label: 'Eval Results', + 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 [debugResults, setDebugResults] = useState(null); + const [debugLoading, setDebugLoading] = useState(false); const hasFetched = useRef(false); useEffect(() => { @@ -115,6 +317,20 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr setConfirmingStop(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) { @@ -139,13 +355,13 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr const executionColor = test.executionStatus === 'RUNNING' ? 'green' : test.executionStatus === 'PAUSED' ? 'yellow' : 'red'; - const helpKeys = 'P pause · R resume · S stop · Esc exit'; + const helpKeys = 'P pause · R resume · S stop · D debug · Esc exit'; // Build status text: only show provisioning status if not ACTIVE const statusPrefix = test.status !== 'ACTIVE' ? `${test.status} ` : ''; - // Duration text - const durationText = test.maxDurationDays ? `${test.maxDurationDays} day max` : ''; + // TODO(post-preview): Re-enable duration display once configurable duration is launched. + const durationText = ''; // Column width for side-by-side variants const colW = 28; @@ -162,9 +378,14 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr {durationText && {durationText}} - {/* ── Header: Line 2 — gateway ────────────────────────── */} + {/* ── Header: Line 2 — gateway URL ─────────────────────── */} + + {`Gateway URL: ${gatewayUrlFromArn(test.gatewayArn)}`} + + + {/* ── Header: Line 3 — online eval ────────────────────── */} - {`Gateway: ${extractId(test.gatewayArn)}`} + {`Online Eval: ${extractId(test.evaluationConfig.onlineEvaluationConfigArn)}`} {/* ── Description (if present) ────────────────────────── */} @@ -257,6 +478,29 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr )} + {/* ── 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 && ( diff --git a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx index 619502b82..f897e6e4e 100644 --- a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx @@ -26,6 +26,8 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD 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 [region, setRegion] = useState('us-east-1'); @@ -54,6 +56,14 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD break; } + // Agents from project spec runtimes + const runtimes = projectSpec.runtimes ?? []; + setAgents(runtimes.map(r => ({ name: r.name }))); + + // Existing HTTP gateways from project spec + const httpGws = projectSpec.httpGateways ?? []; + setExistingHttpGateways(httpGws.map(gw => gw.name)); + // Online eval configs from project spec const evalConfigs = projectSpec.onlineEvalConfigs ?? []; setOnlineEvalConfigs(evalConfigs.map(c => c.name)); @@ -95,7 +105,8 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD void createABTest({ name: config.name, description: config.description || undefined, - gateway: config.gateway, + agent: config.agent, + gatewayChoice: config.gatewayChoice, controlBundle: config.controlBundle, controlVersion: config.controlVersion, treatmentBundle: config.treatmentBundle, @@ -120,6 +131,8 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD return ( 0) { + // Epoch seconds (< 1e12) vs milliseconds (>= 1e12) + const ms = n < 1e12 ? n * 1000 : n; + return new Date(ms).toLocaleString(); + } + return new Date(value).toLocaleString(); +} 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 }[]>; @@ -23,6 +36,8 @@ export function AddABTestScreen({ onComplete, onExit, existingTestNames, + agents, + existingHttpGateways, deployedBundles, onlineEvalConfigs, fetchBundleVersions, @@ -30,6 +45,11 @@ export function AddABTestScreen({ 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] @@ -40,6 +60,16 @@ export function AddABTestScreen({ [onlineEvalConfigs] ); + const gatewayItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = [ + { id: '__create_new__', title: 'Create new HTTP gateway', description: 'Auto-create for this AB test' }, + ]; + for (const gwName of existingHttpGateways) { + items.push({ id: gwName, title: gwName, description: 'Existing HTTP gateway' }); + } + return items; + }, [existingHttpGateways]); + const enableItems: SelectableItem[] = useMemo( () => [ { id: 'yes', title: 'Yes', description: 'Start the AB test immediately after deploy' }, @@ -67,7 +97,7 @@ export function AddABTestScreen({ const items = versions.map(v => ({ id: v.versionId, title: v.versionId.slice(0, 8), - description: `Created: ${new Date(v.createdAt).toLocaleString()}`, + description: `Created: ${formatVersionDate(v.createdAt)}`, })); setControlVersionItems(items); setTreatmentVersionItems(items); @@ -79,20 +109,70 @@ export function AddABTestScreen({ setTreatmentVersionLoadState('error'); }); }, - [deployedBundles, fetchBundleVersions, controlVersionItems.length] + [deployedBundles, fetchBundleVersions] ); // Step flags 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 isMaxDurationStep = wizard.step === 'maxDuration'; + // TODO(post-preview): Re-enable maxDuration step once configurable duration is launched. + // const isMaxDurationStep = wizard.step === 'maxDuration'; const isEnableStep = wizard.step === 'enableOnCreate'; const isConfirmStep = wizard.step === 'confirm'; + // Tell the wizard which steps to skip (both forward and backward navigation). + // The gateway step is skipped when there are no existing gateways — the default + // config already sets gatewayChoice to 'create-new'. + // Track gateway choice type in a ref so the skip check always has the latest value, + // even before React re-renders after setGateway updates state. + const gatewayChoiceTypeRef = React.useRef(wizard.config.gatewayChoice.type); + + const shouldSkipStep = useCallback( + (s: string) => { + if (s === 'gateway' && existingHttpGateways.length === 0) return true; + // Agent selection is only needed when auto-creating a gateway (to set the runtime target). + // When using an existing gateway, the runtime is already configured. + if (s === 'agent' && gatewayChoiceTypeRef.current !== 'create-new') return true; + // TODO(post-preview): Re-enable maxDuration step once configurable duration is launched. + // For public preview, a 14-day default is enforced server-side. + if (s === 'maxDuration') return true; + return false; + }, + [existingHttpGateways.length] + ); + + useEffect(() => { + wizard.setSkipCheck(shouldSkipStep); + }, [shouldSkipStep]); // wizard.setSkipCheck is stable (useCallback with no deps) + // Navigation hooks for select steps + const agentNav = useListNavigation({ + items: agentItems, + onSelect: item => wizard.setAgent(item.id), + onExit: () => wizard.goBack(), + isActive: isAgentStep, + }); + + const gatewayNav = useListNavigation({ + items: gatewayItems, + onSelect: item => { + const choice = + item.id === '__create_new__' + ? ({ type: 'create-new' } as const) + : ({ type: 'existing-http', name: item.id } as const); + // Update ref before setGateway so the skip check sees the new choice + // when advance() runs synchronously in the same call. + gatewayChoiceTypeRef.current = choice.type; + wizard.setGateway(choice); + }, + onExit: () => wizard.goBack(), + isActive: isGatewayStep, + }); + const onlineEvalNav = useListNavigation({ items: onlineEvalItems, onSelect: item => wizard.setOnlineEval(item.id), @@ -115,13 +195,13 @@ export function AddABTestScreen({ }); // Help text - const isSelectStep = isOnlineEvalStep || isEnableStep; + const isSelectStep = isAgentStep || isGatewayStep || isOnlineEvalStep || isEnableStep; const helpText = isSelectStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL : isVariantsStep - ? 'Enter to select · Esc back' + ? HELP_TEXT.VARIANTS_FORM : HELP_TEXT.TEXT_INPUT; const headerContent = ; @@ -154,15 +234,10 @@ export function AddABTestScreen({ /> )} + {isAgentStep && } + {isGatewayStep && ( - wizard.goBack()} - customValidation={(value: string) => (value.trim().length > 0 ? true : 'Gateway ARN is required')} - /> + )} {isVariantsStep && ( @@ -178,38 +253,21 @@ export function AddABTestScreen({ /> )} - {isOnlineEvalStep && ( - - )} + {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. + + ))} - {isMaxDurationStep && ( - { - if (!value.trim()) { - wizard.setMaxDuration(undefined); - return; - } - const n = parseInt(value, 10); - if (!isNaN(n) && n >= 1 && n <= 90) wizard.setMaxDuration(n); - }} - onCancel={() => wizard.goBack()} - customValidation={(value: string) => { - if (!value.trim()) return true; - const n = parseInt(value, 10); - if (isNaN(n)) return 'Must be a number'; - if (n < 1 || n > 90) return 'Must be between 1 and 90'; - return true; - }} - /> - )} + {/* TODO(post-preview): Re-enable maxDuration TextInput once configurable duration is launched. */} {isEnableStep && ( diff --git a/src/cli/tui/screens/ab-test/VariantConfigForm.tsx b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx index ccae5d7d1..f819dac9c 100644 --- a/src/cli/tui/screens/ab-test/VariantConfigForm.tsx +++ b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx @@ -215,6 +215,7 @@ export function VariantConfigForm({ key="weight" prompt={` Treatment weight (1-99) — control will be ${controlWeight}%`} initialValue="20" + onChange={value => setTreatmentWeight(value)} onSubmit={value => { const n = parseInt(value, 10); if (!isNaN(n) && n >= 1 && n <= 99) { diff --git a/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx index 5065835e9..234c45114 100644 --- a/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx +++ b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx @@ -1,4 +1,6 @@ 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'; @@ -25,11 +27,13 @@ function Harness() { interface HarnessHandle { setName: (name: string) => void; setDescription: (desc: string) => void; - setGateway: (gw: 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; } @@ -39,11 +43,13 @@ const ImperativeHarness = React.forwardRef((_, ref) => { 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, })); @@ -52,7 +58,7 @@ const ImperativeHarness = React.forwardRef((_, ref) => { step:{wizard.step} name:{wizard.config.name} description:{wizard.config.description} - gateway:{wizard.config.gateway} + agent:{wizard.config.agent} controlBundle:{wizard.config.controlBundle} treatmentWeight:{wizard.config.treatmentWeight} onlineEval:{wizard.config.onlineEval} @@ -82,10 +88,12 @@ describe('useAddABTestWizard', () => { expect(lastFrame()).toContain('enableOnCreate:true'); }); - it('has all 8 steps', () => { + it('has all 9 steps', () => { const { lastFrame } = render(); const frame = lastFrame()!.replace(/\n/g, ''); - expect(frame).toContain('steps:name,description,gateway,variants,onlineEval,maxDuration,enableOnCreate,confirm'); + expect(frame).toContain( + 'steps:name,description,gateway,agent,variants,onlineEval,maxDuration,enableOnCreate,confirm' + ); }); }); @@ -105,22 +113,34 @@ describe('useAddABTestWizard', () => { const { lastFrame } = render(); act(() => ref.current!.setName('Test1')); - act(() => ref.current!.setDescription('A description')); + act(() => ref.current!.setDescription('desc')); expect(lastFrame()).toContain('step:gateway'); - expect(lastFrame()).toContain('description:A description'); + expect(lastFrame()).toContain('description:desc'); }); - it('setGateway advances to variants', () => { + 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('arn:gateway')); + 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('gateway:arn:gateway'); + expect(lastFrame()).toContain('agent:my-agent'); }); it('setVariants advances to onlineEval', () => { @@ -129,7 +149,8 @@ describe('useAddABTestWizard', () => { act(() => ref.current!.setName('T')); act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway('gw')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + act(() => ref.current!.setAgent('my-agent')); act(() => ref.current!.setVariants({ controlBundle: 'cb', @@ -151,7 +172,8 @@ describe('useAddABTestWizard', () => { act(() => ref.current!.setName('T')); act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway('gw')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + act(() => ref.current!.setAgent('my-agent')); act(() => ref.current!.setVariants({ controlBundle: 'cb', @@ -209,4 +231,56 @@ describe('useAddABTestWizard', () => { 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/types.ts b/src/cli/tui/screens/ab-test/types.ts index 0748c4408..211711cf1 100644 --- a/src/cli/tui/screens/ab-test/types.ts +++ b/src/cli/tui/screens/ab-test/types.ts @@ -5,6 +5,7 @@ export type AddABTestStep = | 'name' | 'description' + | 'agent' | 'gateway' | 'variants' | 'onlineEval' @@ -12,10 +13,13 @@ export type AddABTestStep = | 'enableOnCreate' | 'confirm'; +export type GatewayChoice = { type: 'create-new' } | { type: 'existing-http'; name: string }; + export interface AddABTestConfig { name: string; description: string; - gateway: string; + agent: string; + gatewayChoice: GatewayChoice; controlBundle: string; controlVersion: string; treatmentBundle: string; @@ -29,6 +33,7 @@ export interface AddABTestConfig { export const AB_TEST_STEP_LABELS: Record = { name: 'Name', description: 'Description', + agent: 'Agent', gateway: 'Gateway', variants: 'Variants', onlineEval: 'Eval', diff --git a/src/cli/tui/screens/ab-test/useAddABTestWizard.ts b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts index 7d6411e4d..95fef2402 100644 --- a/src/cli/tui/screens/ab-test/useAddABTestWizard.ts +++ b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts @@ -1,11 +1,12 @@ import type { VariantConfig } from './VariantConfigForm'; -import type { AddABTestConfig, AddABTestStep } from './types'; -import { useCallback, useState } from 'react'; +import type { AddABTestConfig, AddABTestStep, GatewayChoice } from './types'; +import { useCallback, useRef, useState } from 'react'; const ALL_STEPS: AddABTestStep[] = [ 'name', 'description', 'gateway', + 'agent', 'variants', 'onlineEval', 'maxDuration', @@ -17,7 +18,8 @@ function getDefaultConfig(): AddABTestConfig { return { name: '', description: '', - gateway: '', + agent: '', + gatewayChoice: { type: 'create-new' }, controlBundle: '', controlVersion: '', treatmentBundle: '', @@ -29,20 +31,39 @@ function getDefaultConfig(): AddABTestConfig { }; } +export type StepSkipCheck = (step: AddABTestStep) => boolean; + export function useAddABTestWizard() { const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); + const skipCheckRef = useRef(() => false); const currentIndex = ALL_STEPS.indexOf(step); + /** Register a callback that returns true for steps that should be skipped. */ + const setSkipCheck = useCallback((check: StepSkipCheck) => { + skipCheckRef.current = check; + }, []); + const goBack = useCallback(() => { - const prevStep = ALL_STEPS[currentIndex - 1]; - if (prevStep) setStep(prevStep); + // Walk backwards, skipping auto-skippable steps + for (let i = currentIndex - 1; i >= 0; i--) { + if (!skipCheckRef.current(ALL_STEPS[i]!)) { + setStep(ALL_STEPS[i]!); + return; + } + } }, [currentIndex]); const nextStep = useCallback((currentStep: AddABTestStep): AddABTestStep | undefined => { const idx = ALL_STEPS.indexOf(currentStep); - return ALL_STEPS[idx + 1]; + // Walk forwards, skipping auto-skippable steps + for (let i = idx + 1; i < ALL_STEPS.length; i++) { + if (!skipCheckRef.current(ALL_STEPS[i]!)) { + return ALL_STEPS[i]!; + } + } + return undefined; }, []); const advance = useCallback( @@ -69,9 +90,17 @@ export function useAddABTestWizard() { [advance] ); + const setAgent = useCallback( + (agent: string) => { + setConfig(c => ({ ...c, agent })); + advance('agent'); + }, + [advance] + ); + const setGateway = useCallback( - (gateway: string) => { - setConfig(c => ({ ...c, gateway })); + (gatewayChoice: GatewayChoice) => { + setConfig(c => ({ ...c, gatewayChoice })); advance('gateway'); }, [advance] @@ -127,8 +156,10 @@ export function useAddABTestWizard() { steps: ALL_STEPS, currentIndex, goBack, + setSkipCheck, setName, setDescription, + setAgent, setGateway, setVariants, setOnlineEval, diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index b8985caa6..00d742666 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -15,8 +15,10 @@ import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from import { ExecLogger } from '../../../logging'; import { performStackTeardown, setupTransactionSearch } from '../../../operations/deploy'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; -import { setupABTests } from '../../../operations/deploy/post-deploy-ab-tests'; +import { deleteOrphanedABTests, setupABTests } from '../../../operations/deploy/post-deploy-ab-tests'; import { 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, @@ -292,7 +294,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 +310,38 @@ 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 onlineEvalSpecs = ctx.projectSpec.onlineEvalConfigs ?? []; + const deployedOnlineEvalConfigs = deployedState.targets?.[target.name]?.resources?.onlineEvalConfigs ?? {}; + const previouslyDeployedOnlineEvals = existingState?.targets?.[target.name]?.resources?.onlineEvalConfigs ?? {}; + const newOnlineEvalSpecs = onlineEvalSpecs.filter(c => !previouslyDeployedOnlineEvals[c.name]); + if (newOnlineEvalSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { + try { + const enableResult = await enableOnlineEvalConfigs({ + region: target.region, + onlineEvalConfigs: newOnlineEvalSpecs, + 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'); + } + 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'); + setPostDeployWarnings(prev => [...prev, `Online eval enable failed: ${message}`]); + } + } + // Post-deploy: Create/update configuration bundles const configBundleSpecs = ctx.projectSpec.configBundles ?? []; if (configBundleSpecs.length > 0) { @@ -346,6 +380,84 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } } + // 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'); + } + setPostDeployWarnings(prev => [...prev, ...errors.map(err => `AB test "${err.testName}": ${err.error}`)]); + } + + // 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'); + 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'); + } + 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'); + setPostDeployWarnings(prev => [...prev, `HTTP gateway setup failed: ${message}`]); + } + } + // Post-deploy: Create/update AB tests const abTestSpecs = ctx.projectSpec.abTests ?? []; if (abTestSpecs.length > 0) { diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx index d5322e9ed..2740027e1 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx @@ -3,7 +3,7 @@ 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 { AddOnlineEvalScreen } from './AddOnlineEvalScreen'; @@ -101,7 +101,7 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, ); if (flow.name === 'loading') { - return null; + return ; } if (flow.name === 'creds-error') { diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index 7fcda0245..415628b00 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 @@ -71,8 +90,10 @@ 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) - 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 { @@ -90,4 +111,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. {{agent:}}) */ + 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/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 6d83b0210..f27874b11 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -12,6 +12,7 @@ import { AgentCoreGatewaySchema, AgentCoreGatewayTargetSchema, AgentCoreMcpRunti 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, @@ -337,6 +338,16 @@ export const AgentCoreProjectSpecSchema = z 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) => { @@ -373,6 +384,35 @@ 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`, + }); + } + } + } + } }); export type AgentCoreProjectSpec = z.infer; diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index ee2e8aa70..e2d19491a 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -195,6 +195,21 @@ 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; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -209,6 +224,7 @@ export const DeployedResourceStateSchema = z.object({ 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(), stackName: z.string().optional(), diff --git a/src/schema/schemas/primitives/__tests__/ab-test.test.ts b/src/schema/schemas/primitives/__tests__/ab-test.test.ts index ef574a0b9..874cd7d13 100644 --- a/src/schema/schemas/primitives/__tests__/ab-test.test.ts +++ b/src/schema/schemas/primitives/__tests__/ab-test.test.ts @@ -90,7 +90,7 @@ describe('VariantWeightSchema', () => { describe('ABTestSchema', () => { const validABTest = { name: 'TestOne', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', + gatewayRef: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', variants: [ { name: 'C', 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..259fe1a21 --- /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('rejects name over 48 chars', () => { + expect(HttpGatewayNameSchema.safeParse('a'.repeat(49)).success).toBe(false); + }); + + it('accepts name at 48 chars', () => { + expect(HttpGatewayNameSchema.safeParse('a'.repeat(48)).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('rejects name too long (>48 chars)', () => { + expect(HttpGatewaySchema.safeParse({ ...validHttpGateway, name: 'a'.repeat(49) }).success).toBe(false); + }); + + 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/ab-test.ts b/src/schema/schemas/primitives/ab-test.ts index 997667e47..db4b9e211 100644 --- a/src/schema/schemas/primitives/ab-test.ts +++ b/src/schema/schemas/primitives/ab-test.ts @@ -60,7 +60,7 @@ export const ABTestSchema = z .object({ name: ABTestNameSchema, description: ABTestDescriptionSchema, - gatewayArn: z.string().min(1), + gatewayRef: z.string().min(1), roleArn: z.string().min(1).optional(), variants: z.array(ABTestVariantSchema).length(2), evaluationConfig: ABTestEvaluationConfigSchema, diff --git a/src/schema/schemas/primitives/http-gateway.ts b/src/schema/schemas/primitives/http-gateway.ts new file mode 100644 index 000000000..b502882c4 --- /dev/null +++ b/src/schema/schemas/primitives/http-gateway.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +// ============================================================================ +// HTTP Gateway Types +// ============================================================================ + +export const HttpGatewayNameSchema = 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 hyphens (max 48 chars)' + ); + +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(), + }) + .strict(); + +export type HttpGateway = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index c93a844f1..a48985c84 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -65,3 +65,6 @@ export { PolicySchema, ValidationModeSchema, } from './policy'; + +export type { HttpGateway } from './http-gateway'; +export { HttpGatewayNameSchema, HttpGatewaySchema } from './http-gateway'; From f9b84fd8a03ab74b684178080868397bda49d2de Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:33:27 -0400 Subject: [PATCH 27/64] feat: add stop commands and Esc-to-stop for batch eval & recommendation (#65) --- src/cli/commands/pause/command.tsx | 73 +++++++++++++++++++ .../operations/eval/run-batch-evaluation.ts | 3 + .../recommendation/run-recommendation.ts | 1 + src/cli/operations/recommendation/types.ts | 2 + .../recommendation/RecommendationFlow.tsx | 34 ++++++++- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 39 +++++++++- 6 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index cbc0c94e1..d44da4bd6 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -1,5 +1,8 @@ import { ConfigIO } from '../../../lib'; import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; +import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; +import { deleteRecommendation } from '../../aws/agentcore-recommendation'; +import { detectRegion } from '../../aws/region'; import { getErrorMessage } from '../../errors'; import { handlePauseResume } from '../../operations/eval'; import type { OnlineEvalActionOptions } from '../../operations/eval'; @@ -200,4 +203,74 @@ export const registerStop = (program: Command) => { process.exit(1); } }); + + 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(async (cliOptions: { id: string; region?: string; json?: boolean }) => { + try { + const { region: detectedRegion } = await detectRegion(); + const region = cliOptions.region ?? detectedRegion; + + const result = await stopBatchEvaluation({ + region, + batchEvaluateId: cliOptions.id, + }); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`\nBatch evaluation stopped successfully`); + console.log(`ID: ${result.batchEvaluateId}`); + 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); + } + }); + + stopCmd + .command('recommendation') + .description('Stop a running recommendation (deletes the recommendation resource)') + .requiredOption('-i, --id ', 'Recommendation 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: detectedRegion } = await detectRegion(); + const region = cliOptions.region ?? detectedRegion; + + const result = await deleteRecommendation({ + region, + recommendationId: cliOptions.id, + }); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`\nRecommendation stopped successfully`); + console.log(`ID: ${result.recommendationId}`); + 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/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index cdd189884..897007779 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -44,6 +44,8 @@ export interface RunBatchEvaluationOptions { 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: { batchEvaluateId: string; region: string }) => void; } export interface BatchEvaluationResult { @@ -199,6 +201,7 @@ export async function runBatchEvaluationCommand( logger?.endStep('success'); onProgress?.('running', `Batch evaluation started (ID: ${startResult.batchEvaluateId})`); + options.onStarted?.({ batchEvaluateId: startResult.batchEvaluateId, region }); // 4. Poll for completion logger?.startStep('Poll for completion'); diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index abd7dca7b..090de6d92 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -146,6 +146,7 @@ export async function runRecommendationCommand( 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'); diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts index dbba336a6..103558e80 100644 --- a/src/cli/operations/recommendation/types.ts +++ b/src/cli/operations/recommendation/types.ts @@ -50,6 +50,8 @@ export interface RunRecommendationCommandOptions { 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 { diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index d1bdc85cd..4157aaf80 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -2,6 +2,7 @@ import { ConfigIO } from '../../../../lib'; import type { DeployedState } from '../../../../schema'; import { validateAwsCredentials } from '../../../aws/account'; import { listEvaluators } from '../../../aws/agentcore-control'; +import { deleteRecommendation } from '../../../aws/agentcore-recommendation'; import { detectRegion } from '../../../aws/region'; import { getErrorMessage } from '../../../errors'; import { runRecommendationCommand } from '../../../operations/recommendation'; @@ -13,8 +14,8 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { RecommendationScreen } from './RecommendationScreen'; import type { AgentItem, ConfigBundleItem, EvaluatorItem, RecommendationWizardConfig } from './types'; -import { Box, Text } from 'ink'; -import React, { useCallback, useEffect, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; type FlowState = | { name: 'loading' } @@ -25,6 +26,8 @@ type FlowState = configBundles: ConfigBundleItem[]; steps: Step[]; elapsed: number; + recommendationId?: string; + region?: string; } | { name: 'results'; result: RunRecommendationCommandResult; config: RecommendationWizardConfig; filePath?: string } | { name: 'creds-error'; message: string } @@ -36,6 +39,25 @@ interface RecommendationFlowProps { export function RecommendationFlow({ onExit }: RecommendationFlowProps) { const [flow, setFlow] = useState({ name: 'loading' }); + const stoppingRef = useRef(false); + + // Handle Esc to stop a running recommendation + useInput((_input, key) => { + if (flow.name !== 'running' || !flow.recommendationId || !flow.region || stoppingRef.current) return; + if (key.escape) { + stoppingRef.current = true; + void deleteRecommendation({ region: flow.region, recommendationId: flow.recommendationId }).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(() => { @@ -109,6 +131,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { } // Carry configBundles from wizard state so the running effect can look up systemPrompt + stoppingRef.current = false; const bundles = flow.name === 'wizard' ? flow.configBundles : []; setFlow({ name: 'running', config, configBundles: bundles, steps: initialSteps, elapsed: 0 }); }, @@ -208,6 +231,12 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { return { ...prev, steps }; }); }, + onStarted: info => { + setFlow(prev => { + if (prev.name !== 'running') return prev; + return { ...prev, recommendationId: info.recommendationId, region: info.region }; + }); + }, }); clearInterval(timer); @@ -323,6 +352,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { ({timeStr}) + {flow.recommendationId && Press Esc to stop the recommendation} diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 2378d2917..d17c2b39e 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -1,4 +1,5 @@ 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'; @@ -32,7 +33,7 @@ import type { EvaluatorItem } from '../online-eval/types'; import { GroundTruthForm } from './GroundTruthForm'; import type { AgentItem } from './types'; import type { GroundTruthData } from './useRunEvalWizard'; -import { Box, Text } from 'ink'; +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'; @@ -69,7 +70,14 @@ const STEP_LABELS: Record = { type FlowState = | { name: 'loading' } | { name: 'wizard'; agents: AgentItem[]; evaluators: EvaluatorItem[] } - | { name: 'running'; config: BatchEvalConfig; steps: Step[]; elapsed: number } + | { + name: 'running'; + config: BatchEvalConfig; + steps: Step[]; + elapsed: number; + batchEvaluateId?: string; + region?: string; + } | { name: 'results'; result: RunBatchEvaluationCommandResult; savedFilePath?: string } | { name: 'creds-error'; message: string } | { name: 'error'; message: string; logFilePath?: string }; @@ -84,6 +92,25 @@ interface RunBatchEvalFlowProps { 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.batchEvaluateId || !flow.region || stoppingRef.current) return; + if (key.escape) { + stoppingRef.current = true; + void stopBatchEvaluation({ region: flow.region, batchEvaluateId: flow.batchEvaluateId }).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(() => { @@ -161,6 +188,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { }, [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' }, @@ -207,6 +235,12 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { return { ...prev, steps }; }); }, + onStarted: info => { + setFlow(prev => { + if (prev.name !== 'running') return prev; + return { ...prev, batchEvaluateId: info.batchEvaluateId, region: info.region }; + }); + }, }); clearInterval(timer); @@ -311,6 +345,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { ({timeStr}) + {flow.batchEvaluateId && Press Esc to stop the evaluation} From 561e58987da1d66dd30da261bed7c5800555b5a1 Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Fri, 10 Apr 2026 15:41:00 -0400 Subject: [PATCH 28/64] 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. --- .../tui/screens/ab-test/AddABTestScreen.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx index 350052ce9..73fa0c6fc 100644 --- a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx @@ -131,19 +131,15 @@ export function AddABTestScreen({ // even before React re-renders after setGateway updates state. const gatewayChoiceTypeRef = React.useRef(wizard.config.gatewayChoice.type); - const shouldSkipStep = useCallback( - (s: string) => { - if (s === 'gateway' && existingHttpGateways.length === 0) return true; - // Agent selection is only needed when auto-creating a gateway (to set the runtime target). - // When using an existing gateway, the runtime is already configured. - if (s === 'agent' && gatewayChoiceTypeRef.current !== 'create-new') return true; - // TODO(post-preview): Re-enable maxDuration step once configurable duration is launched. - // For public preview, a 14-day default is enforced server-side. - if (s === 'maxDuration') return true; - return false; - }, - [existingHttpGateways.length] - ); + const shouldSkipStep = useCallback((s: string) => { + // Agent selection is only needed when auto-creating a gateway (to set the runtime target). + // When using an existing gateway, the runtime is already configured. + if (s === 'agent' && gatewayChoiceTypeRef.current !== 'create-new') return true; + // TODO(post-preview): Re-enable maxDuration step once configurable duration is launched. + // For public preview, a 14-day default is enforced server-side. + if (s === 'maxDuration') return true; + return false; + }, []); useEffect(() => { wizard.setSkipCheck(shouldSkipStep); From 791dcfaa85773a8a94a2712f885cefe2af6ba7cb Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 10 Apr 2026 14:14:15 -0400 Subject: [PATCH 29/64] 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. --- scripts/bundle.mjs | 14 +++ src/assets/wheels/.gitkeep | 0 .../bedrock_agentcore-1.6.0-py3-none-any.whl | Bin 0 -> 198886 bytes src/lib/packaging/python.ts | 91 +++++++++++------- 4 files changed, 69 insertions(+), 36 deletions(-) create mode 100644 src/assets/wheels/.gitkeep create mode 100644 src/assets/wheels/bedrock_agentcore-1.6.0-py3-none-any.whl diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index 29cb5d745..b14c7ea0a 100644 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -144,6 +144,20 @@ const bundledTarballDest = path.join(cliRoot, 'dist', 'assets', 'bundled-agentco fs.copyFileSync(cdkTarballSrc, bundledTarballDest); log(`Placed CDK tarball at ${bundledTarballDest}`); +// Step 4b: Copy Python SDK wheel into dist/assets/wheels/ if present in src/assets/wheels/ +const srcWheelsDir = path.join(cliRoot, 'src', 'assets', 'wheels'); +const distWheelsDir = path.join(cliRoot, 'dist', 'assets', 'wheels'); +if (fs.existsSync(srcWheelsDir)) { + const wheels = fs.readdirSync(srcWheelsDir).filter(f => f.endsWith('.whl')); + if (wheels.length > 0) { + fs.mkdirSync(distWheelsDir, { recursive: true }); + for (const whl of wheels) { + fs.copyFileSync(path.join(srcWheelsDir, whl), path.join(distWheelsDir, whl)); + log(`Placed Python wheel at dist/assets/wheels/${whl}`); + } + } +} + // Step 5: Bump CLI version and pack into final tarball (includes the bundled CDK tarball) const cliVersionInfo = bumpVersion(cliRoot); try { diff --git a/src/assets/wheels/.gitkeep b/src/assets/wheels/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/assets/wheels/bedrock_agentcore-1.6.0-py3-none-any.whl b/src/assets/wheels/bedrock_agentcore-1.6.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..9bbed78603c1c7f6f998f0c384d74ed881112c8a GIT binary patch literal 198886 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 f.endsWith('.whl')); + } catch { + return false; + } +} + // eslint-disable-next-line security/detect-unsafe-regex -- bounded input from RuntimeVersion enum, not user input const PYTHON_RUNTIME_REGEX = /PYTHON_(\d+)_?(\d+)?/; @@ -110,24 +127,25 @@ export class PythonCodeZipPackager implements RuntimePackager { } await ensureDirClean(stagingDir); - const result = await runSubprocessCapture( - 'uv', - [ - 'pip', - 'install', - '-r', - pyprojectPath, - '--target', - stagingDir, - '--python-version', - pythonVersion, - '--python-platform', - platform, - '--only-binary', - ':all:', - ], - { cwd: projectRoot } - ); + const uvArgs = [ + 'pip', + 'install', + '-r', + pyprojectPath, + '--target', + stagingDir, + '--python-version', + pythonVersion, + '--python-platform', + platform, + '--only-binary', + ':all:', + ]; + if (hasBundledWheels()) { + uvArgs.push('--find-links', BUNDLED_WHEELS_DIR); + } + + const result = await runSubprocessCapture('uv', uvArgs, { cwd: projectRoot }); if (result.code === 0) { await copySourceTree(srcDir, stagingDir); @@ -207,24 +225,25 @@ export class PythonCodeZipPackagerSync implements CodeZipPackager { } ensureDirCleanSync(stagingDir); - const result = runSubprocessCaptureSync( - 'uv', - [ - 'pip', - 'install', - '-r', - pyprojectPath, - '--target', - stagingDir, - '--python-version', - pythonVersion, - '--python-platform', - platform, - '--only-binary', - ':all:', - ], - { cwd: projectRoot } - ); + const uvArgs = [ + 'pip', + 'install', + '-r', + pyprojectPath, + '--target', + stagingDir, + '--python-version', + pythonVersion, + '--python-platform', + platform, + '--only-binary', + ':all:', + ]; + if (hasBundledWheels()) { + uvArgs.push('--find-links', BUNDLED_WHEELS_DIR); + } + + const result = runSubprocessCaptureSync('uv', uvArgs, { cwd: projectRoot }); if (result.code === 0) { copySourceTreeSync(srcDir, stagingDir); From 4f978b741ba5404b86ebd5406d6fb4bccc2b6562 Mon Sep 17 00:00:00 2001 From: notgitika Date: Fri, 10 Apr 2026 18:52:37 -0400 Subject: [PATCH 30/64] 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 --- esbuild.config.mjs | 2 +- package-lock.json | 4 ++-- package.json | 2 +- ...ntcore-1.6.0.dev20260410-py3-none-any.whl} | Bin 198886 -> 199051 bytes src/lib/packaging/python.ts | 5 ++++- 5 files changed, 8 insertions(+), 5 deletions(-) rename src/assets/wheels/{bedrock_agentcore-1.6.0-py3-none-any.whl => bedrock_agentcore-1.6.0.dev20260410-py3-none-any.whl} (93%) 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/package-lock.json b/package-lock.json index 840dcab29..bdef981fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.8.0-evo", + "version": "0.8.0-evo-private-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.8.0-evo", + "version": "0.8.0-evo-private-beta", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 2bb2c8d6e..8acc44a2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.8.0", + "version": "0.8.0-evo-private-beta", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { diff --git a/src/assets/wheels/bedrock_agentcore-1.6.0-py3-none-any.whl b/src/assets/wheels/bedrock_agentcore-1.6.0.dev20260410-py3-none-any.whl similarity index 93% rename from src/assets/wheels/bedrock_agentcore-1.6.0-py3-none-any.whl rename to src/assets/wheels/bedrock_agentcore-1.6.0.dev20260410-py3-none-any.whl index 9bbed78603c1c7f6f998f0c384d74ed881112c8a..a174b8c9a302a625f012dbab67df17dcafa4cda9 100644 GIT binary patch delta 7689 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 Y1}y(;X$K-K8S*?i6jaVJ%wPV00PhyF3jhEB delta 7515 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 Date: Fri, 10 Apr 2026 18:56:37 -0400 Subject: [PATCH 31/64] chore: update CLI version to 0.8.0-evo-private-beta-20260410 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bdef981fb..870e7a39a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.8.0-evo-private-beta", + "version": "0.8.0-evo-private-beta-20260410", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.8.0-evo-private-beta", + "version": "0.8.0-evo-private-beta-20260410", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 8acc44a2d..fa1496533 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.8.0-evo-private-beta", + "version": "0.8.0-evo-private-beta-20260410", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { From deada9a1baef7acb8c7345299c6b55a5c6b1af54 Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Sun, 12 Apr 2026 14:05:03 -0400 Subject: [PATCH 32/64] 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 --- src/cli/aws/agentcore-http-gateways.ts | 4 +-- src/cli/commands/abtest/command.ts | 2 +- src/cli/commands/status/command.tsx | 2 +- src/cli/tui/components/ResourceGraph.tsx | 3 +-- .../screens/ab-test/ABTestDetailScreen.tsx | 27 +++++++++++++++---- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/cli/aws/agentcore-http-gateways.ts b/src/cli/aws/agentcore-http-gateways.ts index 8ace1a4e4..0c5d8bc1e 100644 --- a/src/cli/aws/agentcore-http-gateways.ts +++ b/src/cli/aws/agentcore-http-gateways.ts @@ -335,9 +335,9 @@ export async function listHttpGatewayTargets( path: `/gateways/${options.gatewayId}/targets${query ? `?${query}` : ''}`, }); - const result = data as ListHttpGatewayTargetsResult; + const result = data as Record; return { - targets: result.targets ?? [], + targets: (result.items ?? result.targets ?? []) as HttpGatewayTargetSummary[], }; } diff --git a/src/cli/commands/abtest/command.ts b/src/cli/commands/abtest/command.ts index e828589b6..a6ddcd4b8 100644 --- a/src/cli/commands/abtest/command.ts +++ b/src/cli/commands/abtest/command.ts @@ -62,7 +62,7 @@ function formatABTestDetails(test: GetABTestResult): string { lines.push(`AB Test: ${test.name}`); lines.push(` Status: ${test.status}`); lines.push(` Execution: ${test.executionStatus}`); - lines.push(` Gateway URL: ${gatewayUrlFromArn(test.gatewayArn)}`); + lines.push(` Invocation URL: ${gatewayUrlFromArn(test.gatewayArn)}//invocations`); lines.push(` Online Eval: ${test.evaluationConfig.onlineEvaluationConfigArn}`); if (test.description) lines.push(` Description: ${test.description}`); diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index a306888bf..5a689ee01 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -251,7 +251,7 @@ export const registerStatus = (program: Command) => { {entry.invocationUrl && ( - {' '}Gateway URL: {entry.invocationUrl} + {' '}Invocation URL: {entry.invocationUrl} )} diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 11543aa20..f4c65bfe5 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -104,7 +104,7 @@ function ResourceRow({ )} {invocationUrl && ( - {' '}Gateway URL: {invocationUrl} + {' '}Invocation URL: {invocationUrl} )} @@ -193,7 +193,6 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res statusColor={runtimeStatusColor} deploymentState={rsEntry?.deploymentState} identifier={rsEntry?.identifier} - invocationUrl={rsEntry?.invocationUrl} /> ); })} diff --git a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx index 00cddd0b9..aee831634 100644 --- a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx +++ b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx @@ -2,7 +2,7 @@ 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 } from '../../../aws/agentcore-http-gateways'; +import { getHttpGateway, listHttpGatewayTargets } from '../../../aws/agentcore-http-gateways'; import { getErrorMessage } from '../../../errors'; import { GradientText, Screen } from '../../components'; import { @@ -252,6 +252,7 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr const [confirmingStop, setConfirmingStop] = useState(false); const [debugResults, setDebugResults] = useState(null); const [debugLoading, setDebugLoading] = useState(false); + const [targetName, setTargetName] = useState(''); const hasFetched = useRef(false); useEffect(() => { @@ -261,6 +262,16 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr 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)); } @@ -378,10 +389,16 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr {durationText && {durationText}} - {/* ── Header: Line 2 — gateway URL ─────────────────────── */} - - {`Gateway URL: ${gatewayUrlFromArn(test.gatewayArn)}`} - + {/* ── Header: Line 2 — invocation URL ────────────────────── */} + {targetName ? ( + + {`Invocation URL: ${gatewayUrlFromArn(test.gatewayArn)}/${targetName}/invocations`} + + ) : ( + + Invocation URL: loading... + + )} {/* ── Header: Line 3 — online eval ────────────────────── */} From 5578837e7569a10d09a9b3514ac583e706ecf348 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:57:55 -0400 Subject: [PATCH 33/64] 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 placeholder format in the config bundle schema. This caused the post-deploy config bundle API call to fail with a validation error. * fix: standardize config bundle placeholders to {{runtime:name}} format - Remove {{agent:name}} handler added in previous commit (was never used) - Update schema docs to reference {{runtime:name}} instead of {{agent:name}} - The resolver already handles {{runtime:name}} and {{gateway:name}} * feat: show undeployed runtimes/gateways as placeholder options in config bundle wizard Reads project spec to populate runtimes and gateways that haven't been deployed yet, showing them with placeholder ARNs ({{runtime:name}}, {{gateway:name}}) that resolve to real ARNs at deploy time. Throws descriptive errors if a placeholder can't be resolved during deploy. * docs: document placeholder syntax in config-bundle CLI help Add {{runtime:}} and {{gateway:}} placeholder docs to --components and --components-file help text so CLI-mode users know how to reference undeployed resources. * fix: require json-path flags when using config bundle with recommendations The recommendation API needs explicit JSONPath to locate fields within a config bundle's free-form JSON. Without it, the server default $.components.*.configuration.systemPrompt doesn't match the actual bundle structure (ARN is a top-level key, not nested under components). * feat: auto-resolve short-form system-prompt-json-path from agentcore.json Users can now pass just the field name (e.g. --system-prompt-json-path systemPrompt) instead of the full JSONPath. The CLI looks up the config bundle in agentcore.json, finds the matching field, and resolves placeholder component keys to real ARNs from deployed state. --- .../assets.snapshot.test.ts.snap | 1 + src/assets/cdk/test/cdk.test.ts | 1 + src/cli/aws/agentcore-recommendation.ts | 13 +- src/cli/commands/deploy/actions.ts | 6 + src/cli/commands/run/command.tsx | 62 +++- .../config-bundle/resolve-bundle.ts | 4 +- .../deploy/post-deploy-config-bundles.ts | 8 +- src/cli/operations/deploy/preflight.ts | 21 +- .../operations/eval/run-batch-evaluation.ts | 1 + .../__tests__/apply-to-bundle.test.ts | 199 ++++++++++++ .../__tests__/run-recommendation.test.ts | 18 +- .../recommendation/apply-to-bundle.ts | 140 ++++++++ src/cli/operations/recommendation/index.ts | 2 + .../recommendation/run-recommendation.ts | 171 ++++++++-- src/cli/operations/recommendation/types.ts | 6 +- src/cli/primitives/ConfigBundlePrimitive.ts | 8 +- src/cli/tui/App.tsx | 14 +- src/cli/tui/copy.ts | 8 - src/cli/tui/screens/ab-test/AddABTestFlow.tsx | 28 +- .../tui/screens/ab-test/AddABTestScreen.tsx | 3 + .../tui/screens/ab-test/VariantConfigForm.tsx | 31 +- .../config-bundle/AddConfigBundleFlow.tsx | 105 +++++- .../config-bundle/AddConfigBundleScreen.tsx | 203 ++++++++---- src/cli/tui/screens/config-bundle/types.ts | 33 +- .../config-bundle/useAddConfigBundleWizard.ts | 121 ++++--- src/cli/tui/screens/eval/EvalHubScreen.tsx | 14 +- .../recommendation/RecommendationFlow.tsx | 180 ++++++----- .../RecommendationHistoryScreen.tsx | 2 +- .../recommendation/RecommendationScreen.tsx | 97 +++--- src/cli/tui/screens/recommendation/types.ts | 18 +- .../recommendation/useRecommendationWizard.ts | 23 +- .../run-eval/BatchEvalHistoryScreen.tsx | 302 ++++++++++++++++++ .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 1 + src/cli/tui/screens/run-eval/RunScreen.tsx | 9 +- src/cli/tui/screens/run-eval/index.ts | 1 + src/schema/llm-compacted/agentcore.ts | 2 +- .../schemas/primitives/config-bundle.ts | 4 +- 37 files changed, 1505 insertions(+), 355 deletions(-) create mode 100644 src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts create mode 100644 src/cli/operations/recommendation/apply-to-bundle.ts create mode 100644 src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index e1ffd6872..02ec2a5ae 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: [], 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/cli/aws/agentcore-recommendation.ts b/src/cli/aws/agentcore-recommendation.ts index 7ed98b83f..ce459c90d 100644 --- a/src/cli/aws/agentcore-recommendation.ts +++ b/src/cli/aws/agentcore-recommendation.ts @@ -103,9 +103,15 @@ export interface RecommendationConfig { // Types — Result (tag-union per type) // ============================================================================ +export interface RecommendationResultConfigurationBundle { + bundleArn: string; + versionId: string; +} + export interface SystemPromptRecommendationResult { recommendedSystemPrompt?: string; explanation?: string; + configurationBundle?: RecommendationResultConfigurationBundle; errorCode?: string; errorMessage?: string; } @@ -118,10 +124,7 @@ export interface ToolDescriptionRecommendationToolResult { export interface ToolDescriptionRecommendationResult { tools?: ToolDescriptionRecommendationToolResult[]; - configurationBundle?: { - bundleArn: string; - versionId: string; - }; + configurationBundle?: RecommendationResultConfigurationBundle; errorCode?: string; errorMessage?: string; } @@ -275,7 +278,7 @@ async function signedRequest(options: { if (!response.ok) { const errorBody = await response.text(); - throw new Error(`Recommendation API error (${response.status}): ${errorBody}`); + throw new Error(`Recommendation API error (${response.status}): ${errorBody} [requestId: ${requestId}]`); } if (response.status === 204) return { data: {}, status: 204, requestId }; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 1f8129054..3008fc89a 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -675,6 +675,9 @@ function resolveComponentKey( 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); @@ -682,6 +685,9 @@ function resolveComponentKey( 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/commands/run/command.tsx b/src/cli/commands/run/command.tsx index fc72a13fc..8d71f45cd 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -8,7 +8,11 @@ import type { BatchEvaluationResult, RunBatchEvaluationCommandResult, } from '../../operations/eval/run-batch-evaluation'; -import { runRecommendationCommand, saveRecommendationRun } from '../../operations/recommendation'; +import { + applyRecommendationToBundle, + runRecommendationCommand, + saveRecommendationRun, +} from '../../operations/recommendation'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import type { Command } from '@commander-js/extra-typings'; @@ -271,6 +275,14 @@ export const registerRun = (program: Command) => { .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 ', + 'JSONPath to the system prompt field within the config bundle (e.g. "$.myAgentArn.configuration.systemPrompt")' + ) + .option( + '--tool-desc-json-path ', + 'Tool name:JSONPath pairs for tool descriptions in a config bundle (e.g. --tool-desc-json-path "search:$.myAgentArn.configuration.searchDesc")' + ) .option( '--tools ', 'Tool name:description pairs (repeatable, e.g. --tools "search:Searches the web" --tools "calc:Does math")' @@ -290,6 +302,8 @@ export const registerRun = (program: Command) => { inline?: string; bundleName?: string; bundleVersion?: string; + systemPromptJsonPath?: string; + toolDescJsonPath?: string[]; tools?: string[]; spansFile?: string; lookback: string; @@ -351,6 +365,18 @@ export const registerRun = (program: Command) => { ? ('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, @@ -359,6 +385,8 @@ export const registerRun = (program: Command) => { 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, @@ -367,6 +395,11 @@ export const registerRun = (program: Command) => { region: cliOptions.region, inputSource, traceSource, + onProgress: cliOptions.json + ? undefined + : (_status, message) => { + console.log(message); + }, }); if (!result.success) { @@ -407,7 +440,7 @@ export const registerRun = (program: Command) => { const toolResult = result.result.toolDescriptionRecommendationResult; if (sysResult) { - if (sysResult.explanation) { + if (sysResult.explanation?.trim()) { console.log(`\nWhat changed: ${sysResult.explanation}`); } if (sysResult.recommendedSystemPrompt) { @@ -417,7 +450,9 @@ export const registerRun = (program: Command) => { } else if (toolResult?.tools) { for (const tool of toolResult.tools) { console.log(`\nTool: ${tool.toolName}`); - console.log(`Explanation: ${tool.explanation}`); + if (tool.explanation?.trim()) { + console.log(`Explanation: ${tool.explanation}`); + } console.log(`Recommended: ${tool.recommendedToolDescription}`); } } @@ -426,6 +461,27 @@ export const registerRun = (program: Command) => { 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(''); } diff --git a/src/cli/operations/config-bundle/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts index 8b0dc214f..bfee96934 100644 --- a/src/cli/operations/config-bundle/resolve-bundle.ts +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -32,7 +32,7 @@ export async function resolveBundleByName( if (bundle) { // Verify the deployed-state ID is still valid (bundles may have been recreated) try { - const verified = await getConfigurationBundle({ region, bundleId: bundle.bundleId, branchName: 'main' }); + const verified = await getConfigurationBundle({ region, bundleId: bundle.bundleId }); return { bundleId: bundle.bundleId, bundleArn: bundle.bundleArn, @@ -53,7 +53,7 @@ export async function resolveBundleByName( } // Fetch the bundle to get the latest versionId (required by Recommendation API) - const bundle = await getConfigurationBundle({ region, bundleId: match.bundleId, branchName: 'main' }); + const bundle = await getConfigurationBundle({ region, bundleId: match.bundleId }); return { bundleId: match.bundleId, diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index 621174afa..6fbe72a37 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -54,9 +54,13 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr 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 (like runtimes use projectName_agentName) + const apiBundleName = `${projectName}_${bundleSpec.name}`; + try { // Try to update if we have an existing bundle ID const existingBundle = existingBundles?.[bundleSpec.name]; @@ -125,7 +129,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr if (!updated) { // Try to find by name via list (handles re-creation after state loss) - const existingByName = await findBundleByName(region, bundleSpec.name); + const existingByName = await findBundleByName(region, apiBundleName); if (existingByName) { // Fetch versions and pick the newest — avoids branch-not-found errors from getConfigurationBundle @@ -187,7 +191,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr // Create new — omit branchName if not in spec so the API uses its default const result = await createConfigurationBundle({ region, - bundleName: bundleSpec.name, + bundleName: apiBundleName, description: bundleSpec.description, components: bundleSpec.components as ComponentConfigurationMap, branchName: bundleSpec.branchName, diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 4aa24e71b..68d53c103 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -6,7 +6,7 @@ import { CdkToolkitWrapper, createCdkToolkitWrapper, silentIoHost } from '../../ import { checkBootstrapStatus, checkStacksStatus, formatCdkEnvironment } from '../../cloudformation'; import { cleanupStaleLockFiles } from '../../tui/utils'; import type { IIoHost } from '@aws-cdk/toolkit-lib'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; export interface PreflightContext { @@ -70,6 +70,25 @@ export async function validateProject(): Promise { cdkProject.validate(); const configIO = new ConfigIO({ baseDir: configRoot }); + + // Patch config bundles on disk to include `type: "ConfigurationBundle"` if missing. + // The CDK package now requires this discriminator during synthesis validation. + const specPath = configIO.getPathResolver().getAgentConfigPath(); + const rawJson = JSON.parse(readFileSync(specPath, 'utf-8')) as Record; + const rawBundles = rawJson.configBundles; + if (Array.isArray(rawBundles) && rawBundles.length > 0) { + let patched = false; + for (const b of rawBundles as Record[]) { + if (!b.type) { + b.type = 'ConfigurationBundle'; + patched = true; + } + } + if (patched) { + writeFileSync(specPath, JSON.stringify(rawJson, null, 2), 'utf-8'); + } + } + const projectSpec = await configIO.readProjectSpec(); const awsTargets = await configIO.resolveAWSDeploymentTargets(); diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index 897007779..4e13842b5 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -201,6 +201,7 @@ export async function runBatchEvaluationCommand( logger?.endStep('success'); onProgress?.('running', `Batch evaluation started (ID: ${startResult.batchEvaluateId})`); + onProgress?.('running', 'This may take a few minutes...'); options.onStarted?.({ batchEvaluateId: startResult.batchEvaluateId, region }); // 4. Poll for completion 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..56a70edc3 --- /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', explanation: 'improved' }], + 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__/run-recommendation.test.ts b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts index c49f5b857..7cc443d14 100644 --- a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts +++ b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts @@ -7,7 +7,7 @@ const mockReadDeployedState = vi.fn().mockResolvedValue({ targets: { default: { resources: { - agents: { + runtimes: { MyAgent: { runtimeId: 'rt-abc123', runtimeArn: 'arn:aws:bedrock:us-east-1:998846730471:agent-runtime/rt-abc123', @@ -569,7 +569,8 @@ describe('runRecommendationCommand', () => { expect(serviceNames[0]).toBe('rt.DEFAULT'); }); - it('includes sessionIds in cloudwatch config when provided', async () => { + 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', @@ -586,14 +587,16 @@ describe('runRecommendationCommand', () => { evaluators: ['Builtin.Toxicity'], inputSource: 'inline', inlineContent: 'test', - traceSource: 'cloudwatch', - sessionIds: ['sess-1', 'sess-2'], + traceSource: 'sessions', + sessionIds: ['sess-1'], pollIntervalMs: 0, }); + expect(mockFetchSessionSpans).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1' })); const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const cwConfig = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs; - expect(cwConfig.sessionIds).toEqual(['sess-1', 'sess-2']); + const traces = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces; + expect(traces.sessionSpans).toBeDefined(); + expect(traces.cloudwatchLogs).toBeUndefined(); }); it('builds cloudwatch config with two log group ARNs', async () => { @@ -664,8 +667,7 @@ describe('runRecommendationCommand', () => { expect(result.error).toContain('Insufficient trace data'); expect(result.error).toContain('INSUFFICIENT_DATA'); expect(result.error).toContain('Not enough traces'); - expect(result.error).toContain('start-req-id'); - expect(result.error).toContain('poll-req-id'); + // Request IDs are logged to file only, not included in the error message }); it('passes full ARN evaluator as-is', async () => { 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/index.ts b/src/cli/operations/recommendation/index.ts index 964b183c8..f60a1d798 100644 --- a/src/cli/operations/recommendation/index.ts +++ b/src/cli/operations/recommendation/index.ts @@ -1,3 +1,5 @@ +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'; diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index 090de6d92..54fa3767d 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -107,13 +107,72 @@ export async function runRecommendationCommand( // 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, - bundleName: options.bundleName, + bundleArn, bundleVersion: options.bundleVersion, - systemPromptJsonPath: options.systemPromptJsonPath, + systemPromptJsonPath: resolvedSystemPromptJsonPath, + toolDescJsonPaths: options.toolDescJsonPaths, inputSource: options.inputSource, tools: options.tools, traceSource: options.traceSource, @@ -213,6 +272,7 @@ export async function runRecommendationCommand( recommendationId: startResult.recommendationId, status: currentStatus, result: pollResult.recommendationResult, + region, startedAt: pollResult.createdAt, completedAt: pollResult.completedAt, logFilePath: logger?.logFilePath, @@ -226,19 +286,20 @@ export async function runRecommendationCommand( 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(', '); - const requestIdSuffix = requestIds ? `\n\nRequest IDs (share with API team): ${requestIds}` : ''; + if (requestIds) logger?.log(`Request IDs: ${requestIds}`, 'error'); return { success: false, error: failureDetails - ? `Recommendation failed: ${failureDetails}${requestIdSuffix}` - : `Recommendation finished with status: ${currentStatus}${requestIdSuffix}`, + ? `Recommendation failed: ${failureDetails}` + : `Recommendation finished with status: ${currentStatus}`, recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -318,9 +379,10 @@ function extractAccountIdFromArn(arn: string): string { interface BuildConfigOptions { type: RecommendationType; inlineContent?: string; - bundleName?: string; + bundleArn?: string; bundleVersion?: string; systemPromptJsonPath?: string; + toolDescJsonPaths?: { toolName: string; toolDescriptionJsonPath: string }[]; inputSource: string; tools?: string[]; traceSource: string; @@ -346,16 +408,14 @@ async function buildRecommendationConfig(opts: BuildConfigOptions): Promise 0 - ) { - // Tool-desc with session IDs — auto-fetch from both log groups and use inline sessionSpans. - // The server-side ToolDescRecWorkflowLambda does NOT support cloudwatchLogs, only 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 tool-desc recommendation (cloudwatchLogs not supported server-side)'); + 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) { @@ -382,7 +442,7 @@ async function buildRecommendationConfig(opts: BuildConfigOptions): Promise 0 ? { sessionIds: opts.sessionIds } : {}), }, }; } @@ -406,16 +465,37 @@ async function buildRecommendationConfig(opts: BuildConfigOptions): Promise { const colonIdx = t.indexOf(':'); if (colonIdx > 0) { @@ -472,6 +569,36 @@ function extractFailureDetails(pollResult: { 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 index 103558e80..426ba84a8 100644 --- a/src/cli/operations/recommendation/types.ts +++ b/src/cli/operations/recommendation/types.ts @@ -24,8 +24,10 @@ export interface RunRecommendationCommandOptions { bundleName?: string; /** Config bundle version (when inputSource is 'config-bundle') */ bundleVersion?: string; - /** JSON path to system prompt within the config bundle component (default: $.systemPrompt) */ + /** 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') */ @@ -61,6 +63,8 @@ export interface RunRecommendationCommandResult { 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 */ diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index c40f9d50a..ef2b0ed1d 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -108,8 +108,11 @@ export class ConfigBundlePrimitive extends BasePrimitive', 'Bundle name') .option('--description ', 'Bundle description') - .option('--components ', 'Components map as inline JSON') - .option('--components-file ', 'Path to components JSON file') + .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') @@ -217,6 +220,7 @@ export class ConfigBundlePrimitive extends BasePrimitive setRoute({ name: 'run-eval', from: 'run' })} onRunBatchEval={() => setRoute({ name: 'run-batch-eval', from: 'run' })} + onRunRecommendation={() => setRoute({ name: 'recommend', from: 'run' })} onExit={() => setRoute({ name: 'help' })} /> ); @@ -240,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' })} @@ -262,6 +266,10 @@ function AppContent() { return setRoute({ name: backRoute } as Route)} />; } + if (route.name === 'batch-eval-history') { + return setRoute({ name: 'evals' })} />; + } + if (route.name === 'recommendations-hub') { return ( ', ], }, - evals: { - description: 'View saved eval and batch eval results from past runs.', - examples: [ - 'agentcore evals history', - 'agentcore evals history -r MyAgent --limit 5', - 'agentcore evals history --json', - ], - }, }; diff --git a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx index f897e6e4e..b67bfdc90 100644 --- a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx @@ -3,12 +3,14 @@ import { listConfigurationBundleVersions } from '../../../aws/agentcore-config-b 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 { AddABTestConfig } from './types'; import React, { useCallback, useEffect, useState } from 'react'; type FlowState = | { name: 'create-wizard' } + | { name: 'create-bundle' } | { name: 'create-success'; testName: string } | { name: 'error'; message: string }; @@ -32,8 +34,10 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD const [onlineEvalConfigs, setOnlineEvalConfigs] = useState([]); const [region, setRegion] = useState('us-east-1'); + const [loadEpoch, setLoadEpoch] = useState(0); + useEffect(() => { - const load = async () => { + void (async () => { try { const configIO = new ConfigIO(); const deployedState = await configIO.readDeployedState(); @@ -73,10 +77,8 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD } catch { // No deployed state — lists will be empty } - }; - - void load(); - }, []); + })(); + }, [loadEpoch]); const fetchBundleVersions = useCallback( async (bundleId: string) => { @@ -127,6 +129,21 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD [createABTest] ); + 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 === 'create-wizard') { return ( ); } diff --git a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx index 73fa0c6fc..a2af30a53 100644 --- a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx @@ -30,6 +30,7 @@ interface AddABTestScreenProps { deployedBundles: { name: string; bundleId: string }[]; onlineEvalConfigs: string[]; fetchBundleVersions: (bundleId: string) => Promise<{ versionId: string; createdAt: string }[]>; + onCreateBundle?: () => void; } export function AddABTestScreen({ @@ -41,6 +42,7 @@ export function AddABTestScreen({ deployedBundles, onlineEvalConfigs, fetchBundleVersions, + onCreateBundle, }: AddABTestScreenProps) { const wizard = useAddABTestWizard(); @@ -246,6 +248,7 @@ export function AddABTestScreen({ treatmentVersionLoadState={treatmentVersionLoadState} onComplete={wizard.setVariants} onCancel={() => wizard.goBack()} + onCreateBundle={onCreateBundle} /> )} diff --git a/src/cli/tui/screens/ab-test/VariantConfigForm.tsx b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx index f819dac9c..61f465323 100644 --- a/src/cli/tui/screens/ab-test/VariantConfigForm.tsx +++ b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx @@ -2,7 +2,7 @@ import type { SelectableItem } from '../../components'; import { TextInput, WizardSelect } from '../../components'; import { useListNavigation } from '../../hooks'; import { Box, Text } from 'ink'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; type VariantSubField = 'controlBundle' | 'controlVersion' | 'treatmentBundle' | 'treatmentVersion' | 'treatmentWeight'; @@ -33,6 +33,7 @@ interface VariantConfigFormProps { treatmentVersionLoadState: VersionLoadState; onComplete: (config: VariantConfig) => void; onCancel: () => void; + onCreateBundle?: () => void; } export function VariantConfigForm({ @@ -44,6 +45,7 @@ export function VariantConfigForm({ treatmentVersionLoadState, onComplete, onCancel, + onCreateBundle, }: VariantConfigFormProps) { const [activeField, setActiveField] = useState('controlBundle'); const [controlBundle, setControlBundle] = useState(''); @@ -52,6 +54,15 @@ export function VariantConfigForm({ 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]; @@ -60,8 +71,12 @@ export function VariantConfigForm({ // Navigation for each select sub-field const controlBundleNav = useListNavigation({ - items: bundleItems, + items: augmentedBundleItems, onSelect: item => { + if (item.id === '__create_bundle__') { + onCreateBundle?.(); + return; + } setControlBundle(item.id); fetchVersionItems(item.id); advanceField(); @@ -81,8 +96,12 @@ export function VariantConfigForm({ }); const treatmentBundleNav = useListNavigation({ - items: bundleItems, + items: augmentedBundleItems, onSelect: item => { + if (item.id === '__create_bundle__') { + onCreateBundle?.(); + return; + } setTreatmentBundle(item.id); fetchVersionItems(item.id); advanceField(); @@ -157,10 +176,10 @@ export function VariantConfigForm({ {activeField === 'controlBundle' ? ( - bundleItems.length > 0 ? ( + augmentedBundleItems.length > 0 ? ( ) : ( @@ -190,7 +209,7 @@ export function VariantConfigForm({ {activeField === 'treatmentBundle' ? ( ) : treatmentBundle ? ( diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx index f0155d504..8456e3143 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx @@ -1,12 +1,14 @@ -import { ErrorPrompt } from '../../components'; +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 } from './types'; +import type { AddConfigBundleConfig, DeployedComponent } from './types'; import React, { useCallback, useEffect, useState } from 'react'; type FlowState = - | { name: 'create-wizard' } + | { name: 'loading' } + | { name: 'create-wizard'; deployedComponents: DeployedComponent[] } | { name: 'create-success'; bundleName: string } | { name: 'error'; message: string }; @@ -27,7 +29,72 @@ export function AddConfigBundleFlow({ }: AddConfigBundleFlowProps) { const { createConfigBundle, reset: resetCreate } = useCreateConfigBundle(); const { names: existingNames } = useExistingConfigBundleNames(); - const [flow, setFlow] = useState({ name: 'create-wizard' }); + 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') { @@ -45,18 +112,37 @@ export function AddConfigBundleFlow({ commitMessage: config.commitMessage || `Create ${config.name}`, }).then(result => { if (result.ok) { - setFlow({ name: 'create-success', bundleName: result.bundleName }); + setFlow(prev => { + if (prev.name === 'loading') return prev; + return { name: 'create-success', bundleName: result.bundleName }; + }); return; } - setFlow({ name: 'error', message: result.error }); + 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 ( - + ); } @@ -80,7 +166,10 @@ export function AddConfigBundleFlow({ detail={flow.message} onBack={() => { resetCreate(); - setFlow({ name: 'create-wizard' }); + 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 index c36b4065f..62a6714f7 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -1,13 +1,12 @@ -import { ComponentConfigurationMapSchema, ConfigBundleNameSchema } from '../../../../schema'; +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, ComponentInputMethod } from './types'; -import { CONFIG_BUNDLE_STEP_LABELS, INPUT_METHOD_OPTIONS } from './types'; +import type { AddConfigBundleConfig, ComponentType, DeployedComponent } from './types'; +import { COMPONENT_TYPE_OPTIONS, CONFIG_BUNDLE_STEP_LABELS } from './types'; import { useAddConfigBundleWizard } from './useAddConfigBundleWizard'; -import { existsSync, readFileSync } from 'fs'; import { Box, Text } from 'ink'; import React, { useMemo } from 'react'; @@ -15,53 +14,91 @@ interface AddConfigBundleScreenProps { onComplete: (config: AddConfigBundleConfig) => void; onExit: () => void; existingBundleNames: string[]; + deployedComponents: DeployedComponent[]; } -function validateComponentsJson(value: string): string | true { +function validateConfigJson(value: string): string | true { try { const parsed: unknown = JSON.parse(value); - ComponentConfigurationMapSchema.parse(parsed); + 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 map of component ARN to { configuration: { ... } }'; - } -} - -function validateComponentsFile(value: string): string | true { - if (!value.trim()) return 'File path is required'; - if (!existsSync(value.trim())) return `File not found: ${value.trim()}`; - try { - const raw = readFileSync(value.trim(), 'utf-8'); - return validateComponentsJson(raw); - } catch { - return 'Failed to read file'; + return 'Must be a valid JSON object'; } } -export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames }: AddConfigBundleScreenProps) { +export function AddConfigBundleScreen({ + onComplete, + onExit, + existingBundleNames, + deployedComponents, +}: AddConfigBundleScreenProps) { const wizard = useAddConfigBundleWizard(); - const inputMethodItems: SelectableItem[] = useMemo( - () => INPUT_METHOD_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + 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 isInputMethodStep = wizard.step === 'inputMethod'; - const isComponentsStep = wizard.step === 'components'; + 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 inputMethodNav = useListNavigation({ - items: inputMethodItems, - onSelect: item => wizard.setInputMethod(item.id as ComponentInputMethod), + 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: isInputMethodStep, + isActive: isComponentSelectStep, + }); + + const addAnotherNav = useListNavigation({ + items: addAnotherItems, + onSelect: item => { + if (item.id === 'yes') wizard.addAnotherComponent(); + else wizard.doneAddingComponents(); + }, + onExit: () => wizard.goBack(), + isActive: isAddAnotherStep, }); useListNavigation({ @@ -71,22 +108,18 @@ export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames isActive: isConfirmStep, }); - const helpText = isInputMethodStep - ? HELP_TEXT.NAVIGATE_SELECT - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : HELP_TEXT.TEXT_INPUT; + const helpText = + isComponentTypeStep || isComponentSelectStep || isAddAnotherStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; const headerContent = ( ); - const componentsPreview = - wizard.config.inputMethod === 'file' - ? wizard.config.componentsRaw - : Object.keys(wizard.config.components).length > 0 - ? `${Object.keys(wizard.config.components).length} component(s)` - : ''; + const componentCount = Object.keys(wizard.config.components).length; return ( )} - {isInputMethodStep && ( + {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} /> )} - {isComponentsStep && wizard.config.inputMethod === 'inline' && ( + {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 && ( <> - Expected format: a JSON map of component ARN → configuration - Example: - - {' '}{ "arn:aws:bedrock-agentcore:REGION:ACCOUNT:agent-runtime/RUNTIME": { - "configuration": { "key": "value" } } } + + 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.setComponents(parsed, value); + const parsed = JSON.parse(value) as Record; + wizard.setConfiguration(parsed); }} onCancel={() => wizard.goBack()} - customValidation={validateComponentsJson} + customValidation={validateConfigJson} /> )} - {isComponentsStep && wizard.config.inputMethod === 'file' && ( - { - const raw = readFileSync(value.trim(), 'utf-8'); - const parsed = JSON.parse(raw) as Record }>; - wizard.setComponents(parsed, value.trim()); - }} - onCancel={() => wizard.goBack()} - customValidation={validateComponentsFile} - /> + {isAddAnotherStep && ( + <> + + + {componentCount} component{componentCount !== 1 ? 's' : ''} configured: + + {Object.keys(wizard.config.components).map(arn => ( + + {' '}• {arn} + + ))} + + + )} {isBranchNameStep && ( @@ -200,7 +259,11 @@ export function AddConfigBundleScreen({ onComplete, onExit, existingBundleNames fields={[ { label: 'Name', value: wizard.config.name }, ...(wizard.config.description ? [{ label: 'Description', value: wizard.config.description }] : []), - { label: 'Components', value: componentsPreview }, + { label: 'Components', value: `${componentCount} component(s)` }, + ...Object.entries(wizard.config.components).map(([arn, comp]) => ({ + label: ` ${arn.split('/').pop() ?? arn}`, + value: Object.keys(comp.configuration).join(', '), + })), { label: 'Branch', value: wizard.config.branchName || 'main' }, { label: 'Message', value: wizard.config.commitMessage || `Create ${wizard.config.name}` }, ]} diff --git a/src/cli/tui/screens/config-bundle/types.ts b/src/cli/tui/screens/config-bundle/types.ts index a2c88a29d..dba1ba4e7 100644 --- a/src/cli/tui/screens/config-bundle/types.ts +++ b/src/cli/tui/screens/config-bundle/types.ts @@ -7,36 +7,51 @@ import type { ComponentConfigurationMap } from '../../../../schema'; export type AddConfigBundleStep = | 'name' | 'description' - | 'inputMethod' - | 'components' + | 'componentType' + | 'componentSelect' + | 'configuration' + | 'addAnother' | 'branchName' | 'commitMessage' | 'confirm'; -export type ComponentInputMethod = 'inline' | 'file'; +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; - inputMethod: ComponentInputMethod; 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', - inputMethod: 'Input', - components: 'Components', + componentType: 'Type', + componentSelect: 'Component', + configuration: 'Config', + addAnother: 'More?', branchName: 'Branch', commitMessage: 'Message', confirm: 'Confirm', }; -export const INPUT_METHOD_OPTIONS = [ - { id: 'inline', title: 'Inline JSON', description: 'Enter component configurations as JSON' }, - { id: 'file', title: 'File path', description: 'Load from a JSON file on disk' }, +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 index a64325830..dac3ef6e6 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -1,12 +1,14 @@ import type { ComponentConfigurationMap } from '../../../../schema'; -import type { AddConfigBundleConfig, AddConfigBundleStep, ComponentInputMethod } from './types'; +import type { AddConfigBundleConfig, AddConfigBundleStep, ComponentType } from './types'; import { useCallback, useState } from 'react'; const ALL_STEPS: AddConfigBundleStep[] = [ 'name', 'description', - 'inputMethod', - 'components', + 'componentType', + 'componentSelect', + 'configuration', + 'addAnother', 'branchName', 'commitMessage', 'confirm', @@ -16,7 +18,6 @@ function getDefaultConfig(): AddConfigBundleConfig { return { name: '', description: '', - inputMethod: 'inline', components: {}, componentsRaw: '', branchName: 'main', @@ -35,64 +36,57 @@ export function useAddConfigBundleWizard() { if (prevStep) setStep(prevStep); }, [currentIndex]); - const nextStep = useCallback((currentStep: AddConfigBundleStep): AddConfigBundleStep | undefined => { - const idx = ALL_STEPS.indexOf(currentStep); - return ALL_STEPS[idx + 1]; + const setName = useCallback((name: string) => { + setConfig(c => ({ ...c, name })); + setStep('description'); }, []); - const setName = useCallback( - (name: string) => { - setConfig(c => ({ ...c, name })); - const next = nextStep('name'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setDescription = useCallback( - (description: string) => { - setConfig(c => ({ ...c, description })); - const next = nextStep('description'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setInputMethod = useCallback( - (inputMethod: ComponentInputMethod) => { - setConfig(c => ({ ...c, inputMethod })); - const next = nextStep('inputMethod'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setComponents = useCallback( - (components: ComponentConfigurationMap, raw: string) => { - setConfig(c => ({ ...c, components, componentsRaw: raw })); - const next = nextStep('components'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setBranchName = useCallback( - (branchName: string) => { - setConfig(c => ({ ...c, branchName })); - const next = nextStep('branchName'); - if (next) setStep(next); - }, - [nextStep] - ); - - const setCommitMessage = useCallback( - (commitMessage: string) => { - setConfig(c => ({ ...c, commitMessage })); - const next = nextStep('commitMessage'); - if (next) setStep(next); - }, - [nextStep] - ); + 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()); @@ -107,8 +101,11 @@ export function useAddConfigBundleWizard() { goBack, setName, setDescription, - setInputMethod, - setComponents, + setComponentType, + setSelectedComponent, + setConfiguration, + addAnotherComponent, + doneAddingComponents, setBranchName, setCommitMessage, reset, 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/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index 4157aaf80..2185e4d93 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -5,7 +5,7 @@ import { listEvaluators } from '../../../aws/agentcore-control'; import { deleteRecommendation } from '../../../aws/agentcore-recommendation'; import { detectRegion } from '../../../aws/region'; import { getErrorMessage } from '../../../errors'; -import { runRecommendationCommand } from '../../../operations/recommendation'; +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'; @@ -13,7 +13,13 @@ import type { Step } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { RecommendationScreen } from './RecommendationScreen'; -import type { AgentItem, ConfigBundleItem, EvaluatorItem, RecommendationWizardConfig } from './types'; +import type { + AgentItem, + ConfigBundleField, + ConfigBundleItem, + EvaluatorItem, + RecommendationWizardConfig, +} from './types'; import { Box, Text, useInput } from 'ink'; import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -23,7 +29,6 @@ type FlowState = | { name: 'running'; config: RecommendationWizardConfig; - configBundles: ConfigBundleItem[]; steps: Step[]; elapsed: number; recommendationId?: string; @@ -112,28 +117,23 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { const handleRunComplete = useCallback( (config: RecommendationWizardConfig) => { - const isToolDescWithSessions = - config.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && config.traceSource === 'sessions'; + const willFetchSpans = config.traceSource === 'sessions'; const initialSteps: Step[] = [ - ...(isToolDescWithSessions - ? [{ label: 'Fetching session spans from CloudWatch...', status: 'pending' as const }] - : []), + ...(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 (isToolDescWithSessions) { + if (willFetchSpans) { initialSteps[0] = { ...initialSteps[0]!, status: 'running' }; initialSteps[1] = { ...initialSteps[1]!, status: 'pending' }; } - // Carry configBundles from wizard state so the running effect can look up systemPrompt stoppingRef.current = false; - const bundles = flow.name === 'wizard' ? flow.configBundles : []; - setFlow({ name: 'running', config, configBundles: bundles, steps: initialSteps, elapsed: 0 }); + setFlow({ name: 'running', config, steps: initialSteps, elapsed: 0 }); }, [flow] ); @@ -143,7 +143,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { if (flow.name !== 'running') return; let cancelled = false; - const { config, configBundles } = flow; + const { config } = flow; const startTime = Date.now(); const timer = setInterval(() => { @@ -155,65 +155,37 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { } }, 1000); - // For config-bundle input, look up selected field values from the loaded config bundles - const selectedBundle = - config.inputSource === 'config-bundle' ? configBundles.find(cb => cb.bundleArn === config.bundleName) : undefined; - void (async () => { try { - // Resolve inline content and tools from config bundle fields - let resolvedInlineContent: string | undefined; - let resolvedTools: string[] | undefined; - - if (config.inputSource === 'config-bundle' && selectedBundle) { - if (config.type === 'SYSTEM_PROMPT_RECOMMENDATION') { - // System prompt: single field → use its value as inline content - const fieldName = config.bundleFields[0]; - const fieldValue = fieldName ? selectedBundle.stringFields[fieldName] : undefined; - if (!fieldValue) { - throw new Error(`Field "${fieldName}" not found or empty in the selected config bundle.`); - } - resolvedInlineContent = fieldValue; - } else { - // Tool description: multiple fields → each field becomes toolName:description - resolvedTools = config.bundleFields.map(fieldName => { - const value = selectedBundle.stringFields[fieldName]; - if (!value) { - throw new Error(`Field "${fieldName}" not found or empty in the selected config bundle.`); - } - return `${fieldName}:${value}`; - }); - } - } else if (config.inputSource === 'config-bundle') { - throw new Error('Selected config bundle not found.'); - } - const result = await runRecommendationCommand({ type: config.type, agent: config.agent, evaluators: config.evaluators, - inputSource: config.inputSource === 'config-bundle' ? 'inline' : config.inputSource, - inlineContent: - config.inputSource === 'inline' - ? config.content - : config.inputSource === 'config-bundle' - ? resolvedInlineContent - : undefined, + inputSource: config.inputSource, + inlineContent: config.inputSource === 'inline' ? config.content : undefined, promptFile: config.inputSource === 'file' ? config.content : undefined, - tools: - resolvedTools ?? - (config.tools - ? config.tools - .split(/,(?=[a-zA-Z0-9_\-.]+:)/) - .map(t => t.trim()) - .filter(Boolean) - : 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.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && config.traceSource === 'sessions'; + const hasFetchStep = config.traceSource === 'sessions'; const offset = hasFetchStep ? 1 : 0; setFlow(prev => { @@ -257,7 +229,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { } // Mark polling success, saving running - const hasFetchStep = config.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && config.traceSource === 'sessions'; + const hasFetchStep = config.traceSource === 'sessions'; const offset = hasFetchStep ? 1 : 0; setFlow(prev => { @@ -394,15 +366,46 @@ interface ResultsViewProps { } 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 === 'another') onRunAnother(); + if (item.id === 'apply') void handleApply(); + else if (item.id === 'another') onRunAnother(); else onExit(); }, onExit, @@ -425,7 +428,7 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results {sysResult && ( - {sysResult.explanation && ( + {sysResult.explanation?.trim() && ( What changed: {sysResult.explanation} @@ -451,7 +454,7 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results {toolResult.tools.map(tool => ( {tool.toolName} - Explanation: {tool.explanation} + {tool.explanation?.trim() && Explanation: {tool.explanation}} {tool.recommendedToolDescription} ))} @@ -470,6 +473,16 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results )} + {applyStatus && ( + + {applyStatus.applied ? ( + ✓ {applyStatus.message} + ) : ( + Could not sync: {applyStatus.message} + )} + + )} + {actions.map((action, idx) => { const selected = idx === nav.selectedIndex; @@ -510,6 +523,30 @@ function buildAgentItems(deployedState: DeployedState): AgentItem[] { 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 }> }[] @@ -524,26 +561,15 @@ function buildConfigBundleItems( if (seen.has(name)) continue; seen.add(name); - // Collect all string-valued configuration fields across components - const stringFields: Record = {}; const projBundle = projectBundles.find(pb => pb.name === name); - if (projBundle?.components) { - for (const comp of Object.values(projBundle.components)) { - if (!comp?.configuration) continue; - for (const [key, value] of Object.entries(comp.configuration)) { - if (typeof value === 'string' && value.trim().length > 0) { - stringFields[key] = value; - } - } - } - } + const fields = projBundle?.components ? collectStringFields(projBundle.components, '', '$') : []; bundles.push({ name, bundleId: state.bundleId, bundleArn: state.bundleArn, versionId: state.versionId, - stringFields, + fields, }); } } diff --git a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx index 99ec53001..f2e003a88 100644 --- a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx @@ -166,7 +166,7 @@ function RecommendationDetailView({ record, onBack }: { record: RecommendationRu {toolResult.tools.map(tool => ( {tool.toolName} - Explanation: {tool.explanation} + {tool.explanation && Explanation: {tool.explanation}} {tool.recommendedToolDescription} ))} diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx index 3066ec82b..adac72ef8 100644 --- a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -103,20 +103,11 @@ export function RecommendationScreen({ ); const traceSourceItems: SelectableItem[] = useMemo( - () => - isToolDesc - ? [ - { - id: 'sessions', - title: 'Session IDs', - description: 'Select sessions — spans are auto-fetched from CloudWatch', - }, - ] - : [ - { id: 'cloudwatch', title: 'CloudWatch Logs', description: 'Discover traces from agent runtime logs' }, - { id: 'sessions', title: 'Session IDs', description: 'Provide specific session IDs manually' }, - ], - [isToolDesc] + () => [ + { 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 ────────────────────────────────────────────────────── @@ -268,22 +259,37 @@ export function RecommendationScreen({ isActive: isBundleStep, }); - // Build selectable items for string fields in the selected config bundle + // 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(() => { - const selectedBundle = configBundles.find(cb => cb.bundleArn === wizard.config.bundleName); if (!selectedBundle) return []; - return Object.entries(selectedBundle.stringFields).map(([key, value]) => ({ - id: key, - title: key, - description: value.length > 80 ? value.slice(0, 80) + '…' : value, - })); - }, [configBundles, wizard.config.bundleName]); + 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 => wizard.setBundleFields([item.id]), + 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, }); @@ -292,7 +298,18 @@ export function RecommendationScreen({ const bundleFieldMultiNav = useMultiSelectNavigation({ items: bundleFieldItems, getId: item => item.id, - onConfirm: ids => wizard.setBundleFields(ids), + 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, @@ -378,7 +395,7 @@ export function RecommendationScreen({ label: 'Traces', value: wizard.config.traceSource === 'sessions' - ? `${wizard.config.sessionIds.length} session${wizard.config.sessionIds.length !== 1 ? 's' : ''} selected${isToolDesc ? ' (auto-fetch)' : ''}` + ? `${wizard.config.sessionIds.length} session${wizard.config.sessionIds.length !== 1 ? 's' : ''} selected (auto-fetch)` : `CloudWatch (${wizard.config.days}d)`, }, ]; @@ -487,8 +504,8 @@ export function RecommendationScreen({ {isBundleFieldStep && bundleFieldItems.length === 0 && ( - Select prompt field - No string fields found in this config bundle's configuration. + Select field + No text fields found in this config bundle's configuration. Press Esc to go back and choose a different bundle. )} @@ -498,11 +515,7 @@ export function RecommendationScreen({ title={ isToolDesc ? 'Which field contains the tool description?' : 'Which field contains the system prompt?' } - description={ - isToolDesc - ? 'Field name becomes tool name, value becomes description' - : 'Select the configuration field to use as the system prompt input' - } + description="Select the field — its JSON path will be sent to the API for server-side resolution" items={bundleFieldItems} selectedIndex={bundleFieldNav.selectedIndex} maxVisibleItems={10} @@ -512,7 +525,7 @@ export function RecommendationScreen({ {isBundleFieldStep && bundleFieldItems.length > 0 && !useFieldSingleSelect && ( - {isToolDesc && ( - - Note: CloudWatch trace source is not supported for tool description recommendations. Spans will be - auto-fetched from CloudWatch for the selected sessions. - - )} - - + )} {isDaysStep && ( diff --git a/src/cli/tui/screens/recommendation/types.ts b/src/cli/tui/screens/recommendation/types.ts index aef4920b3..587ea4a20 100644 --- a/src/cli/tui/screens/recommendation/types.ts +++ b/src/cli/tui/screens/recommendation/types.ts @@ -31,6 +31,10 @@ export interface RecommendationWizardConfig { 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 = { @@ -62,11 +66,21 @@ export interface EvaluatorItem { 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 configuration fields across components, keyed by field name. */ - stringFields: Record; + /** 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 index 0bee61af2..94c3c66d1 100644 --- a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -41,11 +41,7 @@ function getAllSteps( steps.push('traceSource'); - // For tool-desc, traceSource is always 'sessions' (cloudwatch not supported server-side). - // The effective traceSource for step logic: - const effectiveTraceSource = type === 'TOOL_DESCRIPTION_RECOMMENDATION' ? 'sessions' : traceSource; - - if (effectiveTraceSource === 'sessions') { + if (traceSource === 'sessions') { // When using session IDs: ask lookback days first (for discovery), then select sessions steps.push('days'); steps.push('sessions'); @@ -72,6 +68,8 @@ function getDefaultConfig(): RecommendationWizardConfig { bundleName: '', bundleVersion: '', bundleFields: [], + systemPromptJsonPath: '', + toolDescJsonPaths: [], }; } @@ -181,8 +179,19 @@ export function useRecommendationWizard() { ); const setBundleFields = useCallback( - (bundleFields: string[]) => { - setConfig(c => ({ ...c, bundleFields })); + ( + 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] 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..ef5d56483 --- /dev/null +++ b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx @@ -0,0 +1,302 @@ +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.batchEvaluateId} + + + 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?.totalSessions != null && ( + + Sessions: {evalRes.totalSessions} total + {evalRes.sessionsCompleted != null && , {evalRes.sessionsCompleted} completed} + {evalRes.sessionsFailed ? , {evalRes.sessionsFailed} 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 index d17c2b39e..8ece98ec5 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -345,6 +345,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { ({timeStr}) + This may take a few minutes... {flow.batchEvaluateId && Press Esc to stop the evaluation} diff --git a/src/cli/tui/screens/run-eval/RunScreen.tsx b/src/cli/tui/screens/run-eval/RunScreen.tsx index a0df9ce8c..a9a797a37 100644 --- a/src/cli/tui/screens/run-eval/RunScreen.tsx +++ b/src/cli/tui/screens/run-eval/RunScreen.tsx @@ -7,10 +7,11 @@ import React, { useMemo } from 'react'; interface RunScreenProps { onRunEval: () => void; onRunBatchEval: () => void; + onRunRecommendation: () => void; onExit: () => void; } -export function RunScreen({ onRunEval, onRunBatchEval, onExit }: RunScreenProps) { +export function RunScreen({ onRunEval, onRunBatchEval, onRunRecommendation, onExit }: RunScreenProps) { const items: SelectableItem[] = useMemo( () => [ { @@ -23,6 +24,11 @@ export function RunScreen({ onRunEval, onRunBatchEval, onExit }: RunScreenProps) 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.', + }, ], [] ); @@ -32,6 +38,7 @@ export function RunScreen({ onRunEval, onRunBatchEval, onExit }: RunScreenProps) 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 c70fb1d14..7c56bd639 100644 --- a/src/cli/tui/screens/run-eval/index.ts +++ b/src/cli/tui/screens/run-eval/index.ts @@ -1,3 +1,4 @@ +export { BatchEvalHistoryScreen } from './BatchEvalHistoryScreen'; export { RunBatchEvalFlow } from './RunBatchEvalFlow'; export { RunEvalFlow } from './RunEvalFlow'; export { RunEvalScreen } from './RunEvalScreen'; diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index 415628b00..5a150a496 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -346,7 +346,7 @@ interface Policy { 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. {{agent:}}) */ + /** 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 diff --git a/src/schema/schemas/primitives/config-bundle.ts b/src/schema/schemas/primitives/config-bundle.ts index d4dc61f74..06702bd3c 100644 --- a/src/schema/schemas/primitives/config-bundle.ts +++ b/src/schema/schemas/primitives/config-bundle.ts @@ -26,7 +26,7 @@ export type ComponentConfiguration = z.infer}}` when the bundle is created + * 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); @@ -35,6 +35,8 @@ export type ComponentConfigurationMap = z.infer Date: Mon, 13 Apr 2026 11:37:31 -0400 Subject: [PATCH 34/64] fix: change default config bundle branch from 'main' to 'mainline' The AgentCore service's default branch is 'mainline', but the CLI was defaulting to 'main'. This caused GetConfigurationBundle to return "Configuration bundle branch not found" (404) when called without an explicit branchName parameter, breaking the recommendations flow. Changed all 7 occurrences across: - ConfigBundlePrimitive (spec default) - useAddConfigBundleWizard (TUI default) - AddConfigBundleFlow (fallback) - AddConfigBundleScreen (display) - post-deploy-config-bundles (create/update fallback) - Tests updated to match --- .../__tests__/post-deploy-config-bundles.test.ts | 16 ++++++++-------- .../deploy/post-deploy-config-bundles.ts | 4 ++-- .../__tests__/apply-to-bundle.test.ts | 2 +- src/cli/primitives/ConfigBundlePrimitive.ts | 2 +- .../config-bundle/AddConfigBundleFlow.tsx | 2 +- .../config-bundle/AddConfigBundleScreen.tsx | 2 +- .../config-bundle/useAddConfigBundleWizard.ts | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) 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 index 16edc86aa..c2da423c3 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -29,7 +29,7 @@ vi.mock('../../../aws/agentcore-config-bundles', () => ({ const REGION = 'us-west-2'; function makeProjectSpec(configBundles: Record[]) { - return { configBundles } as any; + return { name: 'TestProject', configBundles } as any; } describe('setupConfigBundles', () => { @@ -56,7 +56,7 @@ describe('setupConfigBundles', () => { expect(mockCreateConfigurationBundle).toHaveBeenCalledWith( expect.objectContaining({ region: REGION, - bundleName: 'MyBundle', + bundleName: 'TestProject_MyBundle', components: { foo: { type: 'inline', value: 'bar' } }, commitMessage: 'Create MyBundle', }) @@ -88,7 +88,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components: { foo: { type: 'inline', value: 'old' } }, description: undefined, - lineageMetadata: { branchName: 'main' }, + lineageMetadata: { branchName: 'mainline' }, }); mockUpdateConfigurationBundle.mockResolvedValue({ @@ -111,7 +111,7 @@ describe('setupConfigBundles', () => { bundleId: 'b-123', components: { foo: { type: 'inline', value: 'new' } }, parentVersionIds: ['v-1'], - branchName: 'main', + branchName: 'mainline', commitMessage: 'Update MyBundle', }) ); @@ -137,7 +137,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components, description: 'My desc', - lineageMetadata: { branchName: 'main' }, + lineageMetadata: { branchName: 'mainline' }, }); const result = await setupConfigBundles({ @@ -172,7 +172,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components: { a: { type: 'inline', value: '1' }, b: { type: 'inline', value: '2' } }, description: undefined, - lineageMetadata: { branchName: 'main' }, + lineageMetadata: { branchName: 'mainline' }, }); // Spec has same keys in different order @@ -345,11 +345,11 @@ describe('setupConfigBundles', () => { versionId: 'v-latest', components: { old: { type: 'inline', value: 'data' } }, description: undefined, - lineageMetadata: { branchName: 'main' }, + lineageMetadata: { branchName: 'mainline' }, }); mockListConfigurationBundles.mockResolvedValue({ - bundles: [{ bundleId: 'b-found', bundleName: 'MyBundle' }], + bundles: [{ bundleId: 'b-found', bundleName: 'TestProject_MyBundle' }], }); mockListConfigurationBundleVersions.mockResolvedValue({ diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index 6fbe72a37..a672cc6b2 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -94,7 +94,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr 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 ?? 'main'; + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; const result = await updateConfigurationBundle({ region, bundleId: existingBundle.bundleId, @@ -162,7 +162,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr versionId: current.versionId, }); } else { - const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'main'; + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; const result = await updateConfigurationBundle({ region, bundleId: existingByName.bundleId, 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 56a70edc3..b9215f33e 100644 --- a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts +++ b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts @@ -74,7 +74,7 @@ function makeSpec(systemPrompt = 'old prompt') { }, }, }, - branchName: 'main', + branchName: 'mainline', commitMessage: 'Initial', }, ], diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index ef2b0ed1d..da507771d 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -223,7 +223,7 @@ export class ConfigBundlePrimitive extends BasePrimitive { if (result.ok) { diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index 62a6714f7..47d33ddf5 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -264,7 +264,7 @@ export function AddConfigBundleScreen({ label: ` ${arn.split('/').pop() ?? arn}`, value: Object.keys(comp.configuration).join(', '), })), - { label: 'Branch', value: wizard.config.branchName || 'main' }, + { 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/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index dac3ef6e6..daa79173b 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -20,7 +20,7 @@ function getDefaultConfig(): AddConfigBundleConfig { description: '', components: {}, componentsRaw: '', - branchName: 'main', + branchName: 'mainline', commitMessage: '', }; } From ff6df44e02dfdef0a06d675a95ac939d3f94feed Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 13 Apr 2026 11:40:11 -0400 Subject: [PATCH 35/64] Revert "fix: change default config bundle branch from 'main' to 'mainline'" This reverts commit 167ea995ed4c83f59bc98602bd47f49710c908b3. --- .../__tests__/post-deploy-config-bundles.test.ts | 16 ++++++++-------- .../deploy/post-deploy-config-bundles.ts | 4 ++-- .../__tests__/apply-to-bundle.test.ts | 2 +- src/cli/primitives/ConfigBundlePrimitive.ts | 2 +- .../config-bundle/AddConfigBundleFlow.tsx | 2 +- .../config-bundle/AddConfigBundleScreen.tsx | 2 +- .../config-bundle/useAddConfigBundleWizard.ts | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) 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 index c2da423c3..16edc86aa 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -29,7 +29,7 @@ vi.mock('../../../aws/agentcore-config-bundles', () => ({ const REGION = 'us-west-2'; function makeProjectSpec(configBundles: Record[]) { - return { name: 'TestProject', configBundles } as any; + return { configBundles } as any; } describe('setupConfigBundles', () => { @@ -56,7 +56,7 @@ describe('setupConfigBundles', () => { expect(mockCreateConfigurationBundle).toHaveBeenCalledWith( expect.objectContaining({ region: REGION, - bundleName: 'TestProject_MyBundle', + bundleName: 'MyBundle', components: { foo: { type: 'inline', value: 'bar' } }, commitMessage: 'Create MyBundle', }) @@ -88,7 +88,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components: { foo: { type: 'inline', value: 'old' } }, description: undefined, - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); mockUpdateConfigurationBundle.mockResolvedValue({ @@ -111,7 +111,7 @@ describe('setupConfigBundles', () => { bundleId: 'b-123', components: { foo: { type: 'inline', value: 'new' } }, parentVersionIds: ['v-1'], - branchName: 'mainline', + branchName: 'main', commitMessage: 'Update MyBundle', }) ); @@ -137,7 +137,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components, description: 'My desc', - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); const result = await setupConfigBundles({ @@ -172,7 +172,7 @@ describe('setupConfigBundles', () => { versionId: 'v-1', components: { a: { type: 'inline', value: '1' }, b: { type: 'inline', value: '2' } }, description: undefined, - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); // Spec has same keys in different order @@ -345,11 +345,11 @@ describe('setupConfigBundles', () => { versionId: 'v-latest', components: { old: { type: 'inline', value: 'data' } }, description: undefined, - lineageMetadata: { branchName: 'mainline' }, + lineageMetadata: { branchName: 'main' }, }); mockListConfigurationBundles.mockResolvedValue({ - bundles: [{ bundleId: 'b-found', bundleName: 'TestProject_MyBundle' }], + bundles: [{ bundleId: 'b-found', bundleName: 'MyBundle' }], }); mockListConfigurationBundleVersions.mockResolvedValue({ diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index a672cc6b2..6fbe72a37 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -94,7 +94,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr 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 effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'main'; const result = await updateConfigurationBundle({ region, bundleId: existingBundle.bundleId, @@ -162,7 +162,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr versionId: current.versionId, }); } else { - const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'main'; const result = await updateConfigurationBundle({ region, bundleId: existingByName.bundleId, 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 b9215f33e..56a70edc3 100644 --- a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts +++ b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts @@ -74,7 +74,7 @@ function makeSpec(systemPrompt = 'old prompt') { }, }, }, - branchName: 'mainline', + branchName: 'main', commitMessage: 'Initial', }, ], diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index da507771d..ef2b0ed1d 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -223,7 +223,7 @@ export class ConfigBundlePrimitive extends BasePrimitive { if (result.ok) { diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index 47d33ddf5..62a6714f7 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -264,7 +264,7 @@ export function AddConfigBundleScreen({ label: ` ${arn.split('/').pop() ?? arn}`, value: Object.keys(comp.configuration).join(', '), })), - { label: 'Branch', value: wizard.config.branchName || 'mainline' }, + { label: 'Branch', value: wizard.config.branchName || 'main' }, { label: 'Message', value: wizard.config.commitMessage || `Create ${wizard.config.name}` }, ]} /> diff --git a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index daa79173b..dac3ef6e6 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -20,7 +20,7 @@ function getDefaultConfig(): AddConfigBundleConfig { description: '', components: {}, componentsRaw: '', - branchName: 'mainline', + branchName: 'main', commitMessage: '', }; } From f1c44773258d14ebc5500caac56f6cf7a72ec541 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:55:59 -0400 Subject: [PATCH 36/64] fix: change default config bundle branch from 'main' to 'mainline' (#73) The AgentCore service's default branch is 'mainline', but the CLI was defaulting to 'main'. This caused GetConfigurationBundle to return "Configuration bundle branch not found" (404) when called without an explicit branchName parameter, breaking the recommendations flow. Also fixes test helper to include projectName (required after PR #71 added project name prefixing to API bundle names). --- .../deploy/__tests__/post-deploy-config-bundles.test.ts | 6 +++--- src/cli/operations/deploy/post-deploy-config-bundles.ts | 4 ++-- src/cli/primitives/ConfigBundlePrimitive.ts | 2 +- src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx | 2 +- src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx | 2 +- .../tui/screens/config-bundle/useAddConfigBundleWizard.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) 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 index 16edc86aa..66afc8212 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -29,7 +29,7 @@ vi.mock('../../../aws/agentcore-config-bundles', () => ({ const REGION = 'us-west-2'; function makeProjectSpec(configBundles: Record[]) { - return { configBundles } as any; + return { name: 'TestProject', configBundles } as any; } describe('setupConfigBundles', () => { @@ -56,7 +56,7 @@ describe('setupConfigBundles', () => { expect(mockCreateConfigurationBundle).toHaveBeenCalledWith( expect.objectContaining({ region: REGION, - bundleName: 'MyBundle', + bundleName: 'TestProject_MyBundle', components: { foo: { type: 'inline', value: 'bar' } }, commitMessage: 'Create MyBundle', }) @@ -349,7 +349,7 @@ describe('setupConfigBundles', () => { }); mockListConfigurationBundles.mockResolvedValue({ - bundles: [{ bundleId: 'b-found', bundleName: 'MyBundle' }], + bundles: [{ bundleId: 'b-found', bundleName: 'TestProject_MyBundle' }], }); mockListConfigurationBundleVersions.mockResolvedValue({ diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index 6fbe72a37..a672cc6b2 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -94,7 +94,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr 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 ?? 'main'; + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; const result = await updateConfigurationBundle({ region, bundleId: existingBundle.bundleId, @@ -162,7 +162,7 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr versionId: current.versionId, }); } else { - const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'main'; + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; const result = await updateConfigurationBundle({ region, bundleId: existingByName.bundleId, diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index ef2b0ed1d..da507771d 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -223,7 +223,7 @@ export class ConfigBundlePrimitive extends BasePrimitive { if (result.ok) { diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index 62a6714f7..47d33ddf5 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -264,7 +264,7 @@ export function AddConfigBundleScreen({ label: ` ${arn.split('/').pop() ?? arn}`, value: Object.keys(comp.configuration).join(', '), })), - { label: 'Branch', value: wizard.config.branchName || 'main' }, + { 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/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index dac3ef6e6..daa79173b 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -20,7 +20,7 @@ function getDefaultConfig(): AddConfigBundleConfig { description: '', components: {}, componentsRaw: '', - branchName: 'main', + branchName: 'mainline', commitMessage: '', }; } From c1c71389b43dc3358f5d1dc443775d4329d7691f Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Mon, 13 Apr 2026 13:15:54 -0400 Subject: [PATCH 37/64] fix: gateway rollback and trace delivery reliability (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: gateway rollback and trace delivery reliability Two bugs fixed: 1. Rollback didn't wait for target deletion before deleting gateway. Target deletion is async, so gateway delete would fail silently with a conflict, leaving the gateway orphaned on AWS. Fix: add waitForTargetDeletion poll loop in rollback paths. 2. Redeploy never retried trace delivery for existing gateways. If trace delivery failed on initial deploy and the gateway was orphaned, findByName recovery would save it without enabling trace delivery. No subsequent deploy would ever fix it. Fix: add ensureTraceDelivery check on existing/recovered paths. Also fixed substring matching bug in ensureTraceDelivery that could false-positive match gw-1 against gw-10 (includes → endsWith). * fix: address PR review comments - Add console.warn in ensureTraceDelivery catch so failures are visible - Distinguish 404 from transient errors in waitForTargetDeletion polling so a transient 500 doesn't cause early exit --- .../post-deploy-http-gateways.test.ts | 318 +++++++++++++++++- .../deploy/post-deploy-http-gateways.ts | 91 ++++- 2 files changed, 403 insertions(+), 6 deletions(-) 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 index a0b295451..2bd616857 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts @@ -9,6 +9,7 @@ const { mockCreateHttpGatewayTarget, mockDeleteHttpGateway, mockDeleteHttpGatewayTarget, + mockGetHttpGatewayTarget, mockListAllHttpGateways, mockListHttpGatewayTargets, mockWaitForGatewayReady, @@ -21,6 +22,7 @@ const { mockCreateHttpGatewayTarget: vi.fn(), mockDeleteHttpGateway: vi.fn(), mockDeleteHttpGatewayTarget: vi.fn(), + mockGetHttpGatewayTarget: vi.fn(), mockListAllHttpGateways: vi.fn(), mockListHttpGatewayTargets: vi.fn(), mockWaitForGatewayReady: vi.fn(), @@ -35,6 +37,7 @@ vi.mock('../../../aws/agentcore-http-gateways', () => ({ createHttpGatewayTarget: mockCreateHttpGatewayTarget, deleteHttpGateway: mockDeleteHttpGateway, deleteHttpGatewayTarget: mockDeleteHttpGatewayTarget, + getHttpGatewayTarget: mockGetHttpGatewayTarget, listAllHttpGateways: mockListAllHttpGateways, listHttpGatewayTargets: mockListHttpGatewayTargets, waitForGatewayReady: mockWaitForGatewayReady, @@ -45,6 +48,9 @@ vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({ CloudWatchLogsClient: class { send = mockCWLogsSend; }, + DescribeDeliverySourcesCommand: class { + constructor(public input: unknown) {} + }, PutDeliverySourceCommand: class { constructor(public input: unknown) {} }, @@ -125,7 +131,14 @@ describe('setupHttpGateways', () => { mockListHttpGatewayTargets.mockResolvedValue({ targets: [] }); mockWaitForGatewayReady.mockResolvedValue({ gatewayId: 'gw-001', status: 'READY' }); mockWaitForTargetReady.mockResolvedValue({}); - mockCWLogsSend.mockResolvedValue({ deliveryDestination: { arn: 'arn:aws:logs:us-east-1:123:delivery-dest/test' } }); + mockGetHttpGatewayTarget.mockRejectedValue(new Error('(404) Not Found')); + // Default: no existing delivery sources, and PutDeliveryDestination returns an ARN + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ deliverySources: [] }); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:aws:logs:us-east-1:123:delivery-dest/test' } }); + }); }); describe('creation', () => { @@ -437,4 +450,307 @@ describe('setupHttpGateways', () => { expect(statuses).toContain('OrphanGw:deleted'); }); }); + + describe('trace delivery rollback (Problem 1)', () => { + it('waits for target deletion before deleting gateway on trace delivery failure', async () => { + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-trace-fail', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-trace-fail', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-trace-fail' }); + + // Make trace delivery fail + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ deliverySources: [] }); + } + if (cmd.constructor.name === 'PutDeliverySourceCommand') { + return Promise.reject(new Error('AccessDenied')); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); + }); + + // Target still exists on first poll, gone on second — exercises the retry loop + const callOrder: string[] = []; + let getTargetCallCount = 0; + mockDeleteHttpGatewayTarget.mockImplementation(() => { + callOrder.push('deleteTarget'); + return Promise.resolve({ success: true }); + }); + mockGetHttpGatewayTarget.mockImplementation(() => { + getTargetCallCount++; + callOrder.push(`getTarget(${getTargetCallCount})`); + if (getTargetCallCount === 1) { + return Promise.resolve({ targetId: 'tgt-trace-fail', status: 'DELETING' }); + } + return Promise.reject(new Error('(404) Not Found')); + }); + mockDeleteHttpGateway.mockImplementation(() => { + callOrder.push('deleteGateway'); + return Promise.resolve({ 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]!.error).toContain('Trace delivery failed'); + expect(result.results[0]!.error).toContain('AccessDenied'); + + // Verify: delete target → poll (still exists) → poll (404) → delete gateway + expect(callOrder).toEqual(['deleteTarget', 'getTarget(1)', 'getTarget(2)', 'deleteGateway']); + }); + + it('proceeds to delete gateway even when target deletion poll times out', async () => { + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-timeout', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-timeout', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-timeout' }); + + // Make trace delivery fail + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ deliverySources: [] }); + } + if (cmd.constructor.name === 'PutDeliverySourceCommand') { + return Promise.reject(new Error('AccessDenied')); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); + }); + + // Target always exists (never deletes) — will trigger timeout + mockDeleteHttpGatewayTarget.mockResolvedValue({ success: true }); + mockGetHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-timeout', status: 'DELETING' }); + mockDeleteHttpGateway.mockResolvedValue({ success: true }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: sampleDeployedResources, + }); + + // Should still attempt gateway delete after timeout (best-effort) + expect(result.hasErrors).toBe(true); + expect(mockDeleteHttpGateway).toHaveBeenCalled(); + }, 120_000); + + it('rollback cleans up auto-created role on trace delivery failure', async () => { + const gwWithoutRole = { ...sampleHttpGateway, roleArn: undefined }; + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-role-cleanup', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-role-cleanup', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-role-cleanup' }); + mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); + mockDeleteHttpGatewayTarget.mockResolvedValue({ success: true }); + mockDeleteHttpGateway.mockResolvedValue({ success: true }); + + // Make trace delivery fail + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ deliverySources: [] }); + } + if (cmd.constructor.name === 'PutDeliverySourceCommand') { + return Promise.reject(new Error('AccessDenied')); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); + }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([gwWithoutRole]), + deployedResources: sampleDeployedResources, + }); + + expect(result.hasErrors).toBe(true); + // IAM calls: CreateRole, PutRolePolicy (setup), then DeleteRolePolicy, DeleteRole (cleanup) + expect(mockIAMSend).toHaveBeenCalledTimes(4); + const iamCallNames = mockIAMSend.mock.calls.map( + (c: unknown[][]) => (c[0] as { constructor: { name: string } }).constructor.name + ); + expect(iamCallNames).toContain('DeleteRolePolicyCommand'); + expect(iamCallNames).toContain('DeleteRoleCommand'); + }); + }); + + describe('ensureTraceDelivery on redeploy (Problem 2)', () => { + it('enables trace delivery on existing gateway when not configured', async () => { + const existingHttpGateways: Record = { + MyHttpGw: { + gatewayId: 'gw-existing', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing', + targetId: 'tgt-existing', + }, + }; + + const cwCalls: string[] = []; + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + cwCalls.push(cmd.constructor.name); + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ deliverySources: [] }); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); + }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + existingHttpGateways, + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('skipped'); + // Should have run the full delivery chain: Describe → Put Source → Put Dest → Create Delivery + expect(cwCalls).toEqual([ + 'DescribeDeliverySourcesCommand', + 'PutDeliverySourceCommand', + 'PutDeliveryDestinationCommand', + 'CreateDeliveryCommand', + ]); + }); + + it('skips trace delivery on existing gateway when already configured', async () => { + const existingHttpGateways: Record = { + MyHttpGw: { + gatewayId: 'gw-existing', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing', + targetId: 'tgt-existing', + }, + }; + + const cwCalls: string[] = []; + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + cwCalls.push(cmd.constructor.name); + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ + deliverySources: [ + { + name: 'agentcore-gw-traces-MyHttpGw', + resourceArns: ['arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing'], + logType: 'TRACES', + }, + ], + }); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); + }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + existingHttpGateways, + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('skipped'); + // Only DescribeDeliverySources should have been called — no Put/Create + expect(cwCalls).toEqual(['DescribeDeliverySourcesCommand']); + }); + + it('does not false-positive match a gateway with a similar ID prefix', async () => { + const existingHttpGateways: Record = { + MyHttpGw: { + gatewayId: 'gw-1', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1', + targetId: 'tgt-1', + }, + }; + + const cwCalls: string[] = []; + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + cwCalls.push(cmd.constructor.name); + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ + deliverySources: [ + { + name: 'agentcore-gw-traces-OtherGw', + resourceArns: ['arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-10'], + logType: 'TRACES', + }, + ], + }); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); + }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + existingHttpGateways, + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('skipped'); + // gw-10 should NOT match gw-1 — must enable trace delivery + expect(cwCalls).toContain('PutDeliverySourceCommand'); + }); + + it('enables trace delivery on gateway found by name (state loss recovery)', async () => { + mockListAllHttpGateways.mockResolvedValue([ + { + name: 'MyHttpGw', + gatewayId: 'gw-recovered', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-recovered', + }, + ]); + + const cwCalls: string[] = []; + mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { + cwCalls.push(cmd.constructor.name); + if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { + return Promise.resolve({ deliverySources: [] }); + } + return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); + }); + + 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-recovered'); + // Full delivery chain should have been called + expect(cwCalls).toContain('PutDeliverySourceCommand'); + expect(cwCalls).toContain('PutDeliveryDestinationCommand'); + expect(cwCalls).toContain('CreateDeliveryCommand'); + }); + + it('ensureTraceDelivery failure is non-fatal for existing gateways', async () => { + const existingHttpGateways: Record = { + MyHttpGw: { + gatewayId: 'gw-existing', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing', + targetId: 'tgt-existing', + }, + }; + + mockCWLogsSend.mockRejectedValue(new Error('CloudWatch Logs unavailable')); + + 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.hasErrors).toBe(false); + // Gateway data should be preserved intact + expect(result.httpGateways.MyHttpGw).toEqual(existingHttpGateways.MyHttpGw); + }); + }); }); diff --git a/src/cli/operations/deploy/post-deploy-http-gateways.ts b/src/cli/operations/deploy/post-deploy-http-gateways.ts index 1cb317e45..4a4eb1e49 100644 --- a/src/cli/operations/deploy/post-deploy-http-gateways.ts +++ b/src/cli/operations/deploy/post-deploy-http-gateways.ts @@ -5,6 +5,7 @@ import { createHttpGatewayTarget, deleteHttpGateway, deleteHttpGatewayTarget, + getHttpGatewayTarget, listAllHttpGateways, listHttpGatewayTargets, waitForGatewayReady, @@ -13,6 +14,7 @@ import { import { CloudWatchLogsClient, CreateDeliveryCommand, + DescribeDeliverySourcesCommand, PutDeliveryDestinationCommand, PutDeliverySourceCommand, } from '@aws-sdk/client-cloudwatch-logs'; @@ -89,8 +91,8 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom const existingGateway = existingHttpGateways?.[gwSpec.name]; if (existingGateway) { - // Already deployed -- skip - // Gateway already deployed — skip silently + // Already deployed — ensure trace delivery is enabled (may have failed on initial deploy) + await ensureTraceDelivery({ region, gatewayName: gwSpec.name, gatewayArn: existingGateway.gatewayArn }); httpGateways[gwSpec.name] = existingGateway; results.push({ gatewayName: gwSpec.name, @@ -107,6 +109,8 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom 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.` ); + // Ensure trace delivery is enabled (may have failed on initial deploy) + await ensureTraceDelivery({ region, gatewayName: gwSpec.name, gatewayArn: existingByName.gatewayArn }); httpGateways[gwSpec.name] = { gatewayId: existingByName.gatewayId, gatewayArn: existingByName.gatewayArn, @@ -177,7 +181,15 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom targetId: targetResult.targetId, }); } catch (targetErr) { - // Rollback: delete the gateway if target creation/readiness fails + // Rollback: delete target (if created), wait for deletion, then delete gateway + try { + if (targetId) { + await deleteHttpGatewayTarget({ region, gatewayId: createResult.gatewayId, targetId }); + await waitForTargetDeletion({ region, gatewayId: createResult.gatewayId, targetId }); + } + } catch { + // Best-effort target cleanup + } try { await deleteHttpGateway({ region, gatewayId: createResult.gatewayId }); } catch { @@ -210,9 +222,12 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom gatewayArn: createResult.gatewayArn, }); } catch (traceErr) { - // Rollback: delete target, gateway, and role + // Rollback: delete target (and wait for deletion), then gateway, then role try { - if (targetId) await deleteHttpGatewayTarget({ region, gatewayId: createResult.gatewayId, targetId }); + if (targetId) { + await deleteHttpGatewayTarget({ region, gatewayId: createResult.gatewayId, targetId }); + await waitForTargetDeletion({ region, gatewayId: createResult.gatewayId, targetId }); + } } catch { // Best-effort target cleanup } @@ -406,6 +421,72 @@ async function enableGatewayTraceDelivery(options: { // Gateway trace delivery enabled } +/** + * Check if trace delivery is already enabled for a gateway. + * If not, enable it. Failures are logged as warnings (non-fatal for existing gateways). + */ +async function ensureTraceDelivery(options: { + region: string; + gatewayName: string; + gatewayArn: string; +}): Promise { + const { region, gatewayName, gatewayArn } = options; + const credentials = getCredentialProvider(); + const logsClient = new CloudWatchLogsClient({ region, credentials }); + + try { + const sources = await logsClient.send(new DescribeDeliverySourcesCommand({})); + const hasSource = (sources.deliverySources ?? []).some( + s => s.resourceArns?.some(a => a.endsWith(`/${gatewayArn.split('/').pop()!}`)) && s.logType === 'TRACES' + ); + + if (!hasSource) { + await enableGatewayTraceDelivery({ region, gatewayName, gatewayArn }); + } + } catch (err) { + console.warn( + `Warning: Could not verify/enable trace delivery for gateway "${gatewayName}": ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +/** + * Wait for a gateway target to be fully deleted before deleting the gateway. + * Polls getHttpGatewayTarget until it returns 404 or timeout is reached. + */ +async function waitForTargetDeletion(options: { + region: string; + gatewayId: string; + targetId: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = options.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; // Target confirmed deleted + } + // Transient error — keep polling rather than assuming deleted + } + + 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); + } +} + // ============================================================================ // Helpers // ============================================================================ From 3d9633d297531ef2ce82aa3b665deb5ef1966f53 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 13 Apr 2026 15:21:07 -0400 Subject: [PATCH 38/64] chore: configure dev distro (agentcore-dev namespace, disable update nag) - Rename binary from `agentcore` to `agentcore-dev` in package.json bin - Change package name to `@aws/agentcore-dev` - Set DISTRO_MODE to PRIVATE_DEV_DISTRO - Disable update notifier for dev distro (installs from tarball, not npm) - Update Commander program name to `agentcore-dev` - Replace SDK wheel with fresh build from feat/evo_main (1.6.0.dev20260413) --- package-lock.json | 10 +++++----- package.json | 6 +++--- ...ntcore-1.6.0.dev20260413-py3-none-any.whl} | Bin 199051 -> 199052 bytes src/cli/cli.ts | 9 +++++---- src/cli/constants.ts | 8 +++++--- 5 files changed, 18 insertions(+), 15 deletions(-) rename src/assets/wheels/{bedrock_agentcore-1.6.0.dev20260410-py3-none-any.whl => bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl} (95%) diff --git a/package-lock.json b/package-lock.json index 14d2a1016..06510dfe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@aws/agentcore", - "version": "0.8.0-evo-private-beta-20260410", + "name": "@aws/agentcore-dev", + "version": "0.8.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@aws/agentcore", - "version": "0.8.0-evo-private-beta-20260410", + "name": "@aws/agentcore-dev", + "version": "0.8.0-dev", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -39,7 +39,7 @@ "zod": "^4.3.5" }, "bin": { - "agentcore": "dist/cli/index.mjs" + "agentcore-dev": "dist/cli/index.mjs" }, "devDependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.1018.0", diff --git a/package.json b/package.json index 5b92b5272..31c08ad39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@aws/agentcore", - "version": "0.8.0-evo-private-beta-20260410", + "name": "@aws/agentcore-dev", + "version": "0.8.0-dev", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { @@ -30,7 +30,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "agentcore": "dist/cli/index.mjs" + "agentcore-dev": "dist/cli/index.mjs" }, "exports": { ".": { diff --git a/src/assets/wheels/bedrock_agentcore-1.6.0.dev20260410-py3-none-any.whl b/src/assets/wheels/bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl similarity index 95% rename from src/assets/wheels/bedrock_agentcore-1.6.0.dev20260410-py3-none-any.whl rename to src/assets/wheels/bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl index a174b8c9a302a625f012dbab67df17dcafa4cda9..afd3ac66abd7b0872d85fa97511f71db5f2b541a 100644 GIT binary patch delta 3572 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 delta 3576 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 diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 9d3647437..ae54a956e 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -22,7 +22,7 @@ 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 { PACKAGE_VERSION, getDistroConfig } from './constants'; import { getOrCreateInstallationId } from './global-config'; import { ALL_PRIMITIVES } from './primitives'; import { App } from './tui/App'; @@ -128,7 +128,7 @@ export function createProgram(): Command { const program = new Command(); program - .name('agentcore') + .name('agentcore-dev') .description(COMMAND_DESCRIPTIONS.program) .version(PACKAGE_VERSION) .showHelpAfterError() @@ -205,9 +205,10 @@ 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 and dev distro) const isUpdateCommand = args[0] === 'update'; - const updateCheck = isUpdateCommand ? Promise.resolve(null) : checkForUpdate(); + const skipUpdateCheck = isUpdateCommand || !getDistroConfig().checkForUpdates; + const updateCheck = skipUpdateCheck ? Promise.resolve(null) : checkForUpdate(); // Show TUI for no arguments, commander handles --help via configureHelp() if (args.length === 0) { diff --git a/src/cli/constants.ts b/src/cli/constants.ts index 48c3e0375..32d11da3f 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -12,7 +12,7 @@ export const PACKAGE_VERSION: string = packageJson.version; */ export type DistroMode = 'PROD_DISTRO' | 'PRIVATE_DEV_DISTRO'; -export const DISTRO_MODE: DistroMode = 'PROD_DISTRO'; +export const DISTRO_MODE: DistroMode = 'PRIVATE_DEV_DISTRO'; /** * Configuration for each distribution mode. @@ -22,11 +22,13 @@ export const DISTRO_CONFIG = { packageName: '@aws/agentcore', registryUrl: 'https://registry.npmjs.org', installCommand: 'npm install -g @aws/agentcore@latest', + checkForUpdates: true, }, PRIVATE_DEV_DISTRO: { - packageName: '@aws/agentcore', + packageName: '@aws/agentcore-dev', registryUrl: 'https://npm.pkg.github.com', - installCommand: 'npm install -g @aws/agentcore@latest --registry=https://npm.pkg.github.com', + installCommand: 'npm install -g @aws/agentcore-dev@latest --registry=https://npm.pkg.github.com', + checkForUpdates: false, }, } as const; From 922e947f1f4e65c1eea5594412a99506fe66644b Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 13 Apr 2026 15:32:14 -0400 Subject: [PATCH 39/64] fix: resolve config bundle by prefixed API name in fallback path resolveBundleByName fallback searched the list API by user-facing name (e.g. "MyBundle") but the API stores bundles with a project prefix (e.g. "projectName_MyBundle"). When the deployed-state verify call fails, the fallback could not find the bundle despite it existing. Now tries both the raw name and the prefixed name in the list API fallback. --- .../operations/config-bundle/resolve-bundle.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cli/operations/config-bundle/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts index bfee96934..4196b0c7e 100644 --- a/src/cli/operations/config-bundle/resolve-bundle.ts +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -25,6 +25,7 @@ export async function resolveBundleByName( ): Promise { // Fast path: check deployed state const deployedState = await configIO.readDeployedState(); + let projectName: string | undefined; for (const targetName of Object.keys(deployedState.targets ?? {})) { const target = deployedState.targets?.[targetName]; const bundles = target?.resources?.configBundles; @@ -45,9 +46,21 @@ export async function resolveBundleByName( } } + // Read project name for prefixed API-side bundle name lookup + try { + const projectSpec = await configIO.readProjectSpec(); + projectName = projectSpec.name; + } catch { + // Project spec may not be available + } + // Fallback: search via API + // The API stores bundles with a prefixed name: {projectName}_{bundleName} const result = await listConfigurationBundles({ region, maxResults: 100 }); - const match = result.bundles.find(b => b.bundleName === bundleName); + const prefixedName = projectName ? `${projectName}_${bundleName}` : undefined; + const match = result.bundles.find( + b => b.bundleName === bundleName || (prefixedName && b.bundleName === prefixedName) + ); if (!match) { throw new Error(`Configuration bundle "${bundleName}" not found. Has it been deployed?`); } From f4eb871c98585febecd586eae8376c87a8ac5305 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 13 Apr 2026 15:35:40 -0400 Subject: [PATCH 40/64] fix: use branch-agnostic version check in config bundle resolution Replace getConfigurationBundle (requires branch, fails with "branch not found") with listConfigurationBundleVersions (branch-agnostic) for both the deployed-state verify path and the list API fallback path. Also match bundles by prefixed API name ({project}_{bundle}) in the list API fallback, since the API stores bundles with a project prefix but users reference them by the short name from agentcore.json. --- .../config-bundle/resolve-bundle.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/cli/operations/config-bundle/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts index 4196b0c7e..57ca4c699 100644 --- a/src/cli/operations/config-bundle/resolve-bundle.ts +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -5,7 +5,7 @@ * Fallback: calls listConfigurationBundles API to find by name. */ import { ConfigIO } from '../../../lib'; -import { getConfigurationBundle, listConfigurationBundles } from '../../aws/agentcore-config-bundles'; +import { listConfigurationBundleVersions, listConfigurationBundles } from '../../aws/agentcore-config-bundles'; export interface ResolvedBundle { bundleId: string; @@ -25,19 +25,23 @@ export async function resolveBundleByName( ): Promise { // Fast path: check deployed state const deployedState = await configIO.readDeployedState(); - let projectName: string | undefined; 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 deployed-state ID is still valid (bundles may have been recreated) + // Verify the bundle still exists by listing versions (branch-agnostic) try { - const verified = await getConfigurationBundle({ region, bundleId: bundle.bundleId }); + const versions = await listConfigurationBundleVersions({ + region, + bundleId: bundle.bundleId, + maxResults: 1, + }); + const latestVersion = versions.versions[0]; return { bundleId: bundle.bundleId, bundleArn: bundle.bundleArn, - versionId: verified.versionId, + versionId: latestVersion?.versionId ?? bundle.versionId, region, }; } catch { @@ -46,7 +50,9 @@ export async function resolveBundleByName( } } - // Read project name for prefixed API-side bundle name 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; @@ -54,8 +60,6 @@ export async function resolveBundleByName( // Project spec may not be available } - // Fallback: search via API - // The API stores bundles with a prefixed name: {projectName}_{bundleName} const result = await listConfigurationBundles({ region, maxResults: 100 }); const prefixedName = projectName ? `${projectName}_${bundleName}` : undefined; const match = result.bundles.find( @@ -65,13 +69,18 @@ export async function resolveBundleByName( throw new Error(`Configuration bundle "${bundleName}" not found. Has it been deployed?`); } - // Fetch the bundle to get the latest versionId (required by Recommendation API) - const bundle = await getConfigurationBundle({ region, bundleId: match.bundleId }); + // 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: bundle.versionId, + versionId: latestVersion?.versionId, region, }; } From a5753090f0ef2563b0d58b44a7b97936ebdc0fd4 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 13 Apr 2026 16:04:09 -0400 Subject: [PATCH 41/64] fix: remove configBundles from vended CDK test to avoid type error configBundles is optional with a default in the schema. Including it in the vended test causes TS2353 when existing projects have an older CDK package that doesn't export the field. Removing it ensures backwards compatibility with all CDK versions. --- .../__tests__/__snapshots__/assets.snapshot.test.ts.snap | 3 ++- src/assets/cdk/test/cdk.test.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 94503e16e..415d1da06 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -382,7 +382,6 @@ test('AgentCoreStack synthesizes with empty spec', () => { credentials: [], evaluators: [], onlineEvalConfigs: [], - configBundles: [], policyEngines: [], agentCoreGateways: [], mcpRuntimeTools: [], @@ -523,6 +522,8 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/mcp/standalone/base/main.py", "python/mcp/standalone/base/pyproject.toml", "typescript/.gitkeep", + "wheels/.gitkeep", + "wheels/bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl", ] `; diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 79282f729..df5c767f9 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -14,7 +14,6 @@ test('AgentCoreStack synthesizes with empty spec', () => { credentials: [], evaluators: [], onlineEvalConfigs: [], - configBundles: [], policyEngines: [], agentCoreGateways: [], mcpRuntimeTools: [], From 385d996c57e2d69c8f94ca6f800df29a63b3b148 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 13 Apr 2026 16:11:57 -0400 Subject: [PATCH 42/64] revert: restore configBundles in vended CDK test (required by new CDK) --- src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap | 1 + src/assets/cdk/test/cdk.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 415d1da06..3c84e64f3 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: [], 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: [], From 5b0a14a955b6cfcac82ab7075c92d4f81ff9feb3 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 13 Apr 2026 16:58:33 -0400 Subject: [PATCH 43/64] fix: display full version IDs in config bundle CLI and TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove shortId() truncation (8 chars) from config bundle version display. Full UUIDs are now shown in versions list, diff headers, and parent references — both in CLI output and TUI screens. --- src/cli/commands/config-bundle/command.tsx | 12 ++++-------- .../tui/screens/config-bundle-hub/DiffScreen.tsx | 6 +----- .../config-bundle-hub/VersionHistoryScreen.tsx | 15 +++++---------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx index 05611ef2b..c7dd746ee 100644 --- a/src/cli/commands/config-bundle/command.tsx +++ b/src/cli/commands/config-bundle/command.tsx @@ -25,10 +25,6 @@ function formatTimestamp(ts: string): string { .replace(/\.\d+Z$/, 'Z'); } -function shortId(versionId: string): string { - return versionId.slice(0, 8); -} - async function resolveRegion(): Promise { const { ConfigIO } = await import('../../../lib'); const configIO = new ConfigIO(); @@ -170,14 +166,14 @@ export const registerConfigBundle = (program: Command) => { return ( - {connector} {shortId(v.versionId)}{' '} + {connector} {v.versionId}{' '} {formatTimestamp(v.versionCreatedAt)}{' '} {message && "{message}"} {isLast ? ' ' : '│'} by: {creator} {meta?.parentVersionIds?.length ? ( - (parent: {meta.parentVersionIds.map(id => shortId(id)).join(', ')}) + (parent: {meta.parentVersionIds.join(', ')}) ) : null} @@ -185,7 +181,7 @@ export const registerConfigBundle = (program: Command) => { })} ))} - Full version IDs: use --json for complete output + Use --json for complete output ); } catch (error) { @@ -220,7 +216,7 @@ export const registerConfigBundle = (program: Command) => { render( - Diff: {shortId(result.fromVersion.versionId)} → {shortId(result.toVersion.versionId)} + Diff: {result.fromVersion.versionId} → {result.toVersion.versionId} From: {fromMeta?.commitMessage ?? '(no message)'} ({formatTimestamp(result.fromVersion.versionCreatedAt)}) diff --git a/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx b/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx index 5927cc2eb..f91b2b00e 100644 --- a/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx +++ b/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx @@ -18,10 +18,6 @@ function formatTimestamp(epochSeconds: string): string { .replace(/\.\d+Z$/, 'Z'); } -function shortId(versionId: string): string { - return versionId.slice(0, 8); -} - interface DiffScreenProps { bundleId: string; bundleName: string; @@ -65,7 +61,7 @@ export function DiffScreen({ bundleId, bundleName, fromVersionId, toVersionId, r const result: { text: string; color?: string }[] = []; result.push({ - text: `Diff: ${shortId(fromVersion.versionId)} → ${shortId(toVersion.versionId)}`, + text: `Diff: ${fromVersion.versionId} → ${toVersion.versionId}`, }); result.push({ text: `From: ${fromVersion.lineageMetadata?.commitMessage ?? '(no message)'} (${formatTimestamp(fromVersion.versionCreatedAt)})`, diff --git a/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx b/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx index 12beaa6bf..56e161c85 100644 --- a/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx +++ b/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx @@ -19,10 +19,6 @@ function formatTimestamp(epochSeconds: string): string { .replace(/\.\d+Z$/, 'Z'); } -function shortId(versionId: string): string { - return versionId.slice(0, 8); -} - interface VersionHistoryScreenProps { bundle: BundleWithMeta; region: string; @@ -130,7 +126,7 @@ export function VersionHistoryScreen({ bundle, region, onViewDiff, onExit }: Ver lines.push(`Created by: ${cb.name}${cb.arn ? ` (${cb.arn})` : ''}`); } if (detail.lineageMetadata?.parentVersionIds?.length) { - lines.push(`Parent: ${detail.lineageMetadata.parentVersionIds.map(id => shortId(id)).join(', ')}`); + lines.push(`Parent: ${detail.lineageMetadata.parentVersionIds.map(id => id).join(', ')}`); } lines.push(`Created: ${formatTimestamp(detail.versionCreatedAt)}`); lines.push(''); @@ -176,7 +172,7 @@ export function VersionHistoryScreen({ bundle, region, onViewDiff, onExit }: Ver 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: ${shortId(diffFromId!)})`; + helpText = `↑↓ navigate · Enter select TO version · Esc back (from: ${diffFromId!})`; } // Mode-specific header @@ -190,7 +186,7 @@ export function VersionHistoryScreen({ bundle, region, onViewDiff, onExit }: Ver } else if (mode === 'diff-select-to') { modeIndicator = ( - From: {shortId(diffFromId!)} — Now select the TO version: + From: {diffFromId!} — Now select the TO version: ); } @@ -227,7 +223,7 @@ export function VersionHistoryScreen({ bundle, region, onViewDiff, onExit }: Ver {isSelected ? '❯' : ' '} {connector} - {shortId(v.versionId)} + {v.versionId} {formatTimestamp(v.versionCreatedAt)} {message ? "{message}" : null} @@ -235,8 +231,7 @@ export function VersionHistoryScreen({ bundle, region, onViewDiff, onExit }: Ver {meta?.parentVersionIds?.length ? ( {' '} - {isLast ? ' ' : '│'}{' '} - parent: {meta.parentVersionIds.map(id => shortId(id)).join(', ')} + {isLast ? ' ' : '│'} parent: {meta.parentVersionIds.join(', ')} ) : null} From e75ad4afd635be7fe19f7edc1ecb43357bad88f2 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 13 Apr 2026 17:33:59 -0400 Subject: [PATCH 44/64] fix: remove underscore separator from config bundle API name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change config bundle API name from {project}_{bundle} to {project}{bundle} (no separator). Only affects config bundles — runtimes still use underscore separator. --- src/cli/operations/config-bundle/resolve-bundle.ts | 4 ++-- src/cli/operations/deploy/post-deploy-config-bundles.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/operations/config-bundle/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts index 57ca4c699..b825ed914 100644 --- a/src/cli/operations/config-bundle/resolve-bundle.ts +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -51,7 +51,7 @@ export async function resolveBundleByName( } // Fallback: search via API - // The API stores bundles with a prefixed name: {projectName}_{bundleName} + // The API stores bundles with a prefixed name: {projectName}{bundleName} let projectName: string | undefined; try { const projectSpec = await configIO.readProjectSpec(); @@ -61,7 +61,7 @@ export async function resolveBundleByName( } const result = await listConfigurationBundles({ region, maxResults: 100 }); - const prefixedName = projectName ? `${projectName}_${bundleName}` : undefined; + const prefixedName = projectName ? `${projectName}${bundleName}` : undefined; const match = result.bundles.find( b => b.bundleName === bundleName || (prefixedName && b.bundleName === prefixedName) ); diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index a672cc6b2..113a3c420 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -58,8 +58,8 @@ export async function setupConfigBundles(options: SetupConfigBundlesOptions): Pr // Create or update bundles from the spec for (const bundleSpec of projectSpec.configBundles) { - // Prepend project name to the API-side bundle name (like runtimes use projectName_agentName) - const apiBundleName = `${projectName}_${bundleSpec.name}`; + // 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 From e6036f510b618449110d22ace534dadc49cc2766 Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Wed, 15 Apr 2026 12:42:21 -0400 Subject: [PATCH 45/64] fix: deploy reconciliation, TUI deploy reliability, and region fixes (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stop running AB test before deletion during deploy reconciliation When a user removes an AB test from agentcore.json and redeploys, deleteOrphanedABTests tried to delete it directly. This failed with 409 "Cannot delete while AB test is running", which then blocked gateway deletion (AB test routing rules still on the gateway). Fix: call updateABTest with executionStatus=STOPPED before deleting. If stop fails (already stopped or invalid state), proceed with delete. A console.warn is emitted when an AB test is stopped during cleanup. * fix: use console.warn instead of console.log in deploy operations console.log writes to stdout which corrupts the TUI (Ink) rendering. console.warn writes to stderr which doesn't interfere with the TUI. * fix: poll for AB test status after stop before deleting The stop call transitions the AB test to UPDATING status. Deleting immediately fails with 409 "cannot be deleted in status UPDATING". Now polls getABTest until status leaves UPDATING before attempting delete. * fix: surface AB test stop warning via postDeployWarnings instead of console.warn console.warn leaks into TUI rendering. Instead, return warning in the result and let TUI/CLI callers surface it through proper channels (postDeployWarnings for TUI, logger.log for CLI). * fix: move target deletion wait into deleteHttpGatewayTarget and cleanup - deleteHttpGatewayTarget now polls until target is fully deleted (404) internally, so callers don't need to remember to wait separately - Removed waitForTargetDeletion from post-deploy-http-gateways.ts - Reconciliation deletion path now waits for target deletion too - AB test stop polling now checks executionStatus === 'STOPPED' - Removed console.warn/log that leaked into TUI rendering - Removed debug process.stderr.write logs * fix: resolve config bundle placeholders in TUI deploy path resolveConfigBundleComponentKeys (which resolves {{runtime:name}} and {{gateway:name}} to real ARNs) was only in the CLI deploy path. The TUI deploy path passed raw placeholders to the API, causing validation errors. Moved the resolution functions to post-deploy-config-bundles.ts so both CLI and TUI can import them. * fix: rename --agent to --runtime and clarify --online-eval in ab-test CLI - --agent renamed to --runtime (consistent with other commands) - --online-eval description changed from "name or ARN" to "name" - --gateway help text updated to reference --runtime * test: fix broken polling test and add coverage for review findings - Fix AB test polling test mock: first poll returns executionStatus 'RUNNING' (was 'STOPPED', causing loop to exit immediately — test was broken) - Add 11 tests for resolveConfigBundleComponentKeys (runtime, gateway, ARN passthrough, missing resource errors, immutability) - Add 4 tests for warning field (stop warning set, not set on failure, set even on delete failure, poll timeout) * fix: deleteHttpGatewayTarget returns failure on polling timeout Both reviewers flagged: returning success on timeout is wrong — the target may still be DELETING, causing downstream gateway delete to fail. Now returns { success: false } with timeout error message. * fix: AB test TUI reads region from aws-targets.json instead of env vars ABTestPickerScreen was using process.env.AWS_REGION with us-east-1 fallback. This caused debug checks, stop/resume, and all API calls to hit the wrong region. Now reads from aws-targets.json via resolveAWSDeploymentTargets(), matching the config bundle hub pattern. * fix: paginate DescribeDeliverySources in AB test debug check The debug panel only read the first page of delivery sources, missing sources for accounts with many gateways. Now paginates both DescribeDeliverySources and DescribeDeliveries calls. Also reads region from aws-targets.json instead of env vars. * fix: warn when AB test stop polling times out before deletion Address review comment — log a warning via the result's warning field when the polling loop exhausts all 20 iterations without executionStatus reaching STOPPED, so the user knows the delete is proceeding without confirmation. --- src/cli/aws/agentcore-http-gateways.ts | 32 +++- src/cli/commands/deploy/actions.ts | 66 ++----- .../__tests__/post-deploy-ab-tests.test.ts | 153 ++++++++++++++-- .../post-deploy-config-bundles.test.ts | 164 +++++++++++++++++- .../post-deploy-http-gateways.test.ts | 55 +----- .../operations/deploy/post-deploy-ab-tests.ts | 36 +++- .../deploy/post-deploy-config-bundles.ts | 63 ++++++- .../deploy/post-deploy-http-gateways.ts | 51 +----- src/cli/primitives/ABTestPrimitive.ts | 13 +- .../screens/ab-test/ABTestDetailScreen.tsx | 35 +++- .../screens/ab-test/ABTestPickerScreen.tsx | 7 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 17 +- 12 files changed, 504 insertions(+), 188 deletions(-) diff --git a/src/cli/aws/agentcore-http-gateways.ts b/src/cli/aws/agentcore-http-gateways.ts index 0c5d8bc1e..723150d93 100644 --- a/src/cli/aws/agentcore-http-gateways.ts +++ b/src/cli/aws/agentcore-http-gateways.ts @@ -350,7 +350,37 @@ export async function deleteHttpGatewayTarget( method: 'DELETE', path: `/gateways/${options.gatewayId}/targets/${options.targetId}`, }); - return { success: true }; + + // 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) }; } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 3008fc89a..655ec057c 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,5 +1,5 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; -import type { AgentCoreMcpSpec, AgentCoreProjectSpec, DeployedState } from '../../../schema'; +import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; import { @@ -32,7 +32,10 @@ import { } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; -import { setupConfigBundles } from '../../operations/deploy/post-deploy-config-bundles'; +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'; @@ -484,6 +487,11 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'deleted')) { const updatedState = await configIO.readDeployedState().catch(() => deployedState); @@ -640,55 +648,5 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { - 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; -} +// resolveConfigBundleComponentKeys and resolveComponentKey moved to +// src/cli/operations/deploy/post-deploy-config-bundles.ts 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 780f15ad0..7e7848c5a 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 @@ -4,19 +4,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────────── -const { mockCreateABTest, mockDeleteABTest, mockListABTests, mockGetCredentialProvider, mockIAMSend } = vi.hoisted( - () => ({ - mockCreateABTest: vi.fn(), - mockDeleteABTest: vi.fn(), - mockListABTests: vi.fn(), - mockGetCredentialProvider: vi.fn().mockReturnValue(undefined), - mockIAMSend: vi.fn(), - }) -); +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, })); @@ -87,6 +97,8 @@ describe('setupABTests', () => { beforeEach(() => { vi.clearAllMocks(); mockListABTests.mockResolvedValue({ abTests: [] }); + mockUpdateABTest.mockResolvedValue({}); + mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'STOPPED' }); }); describe('creation', () => { @@ -283,8 +295,25 @@ describe('setupABTests', () => { }); describe('deletion (reconciliation)', () => { - it('deletes orphaned AB test not in project spec', async () => { - mockDeleteABTest.mockResolvedValue({ success: true }); + 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', @@ -294,7 +323,30 @@ describe('setupABTests', () => { }, }); - expect(mockDeleteABTest).toHaveBeenCalledWith({ region: 'us-east-1', abTestId: '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'); }); @@ -363,6 +415,83 @@ describe('setupABTests', () => { 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', () => { 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 index 66afc8212..d0c513aa0 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -1,4 +1,5 @@ -import { setupConfigBundles } from '../post-deploy-config-bundles.js'; +import type { AgentCoreProjectSpec, DeployedState } from '../../../../schema'; +import { resolveConfigBundleComponentKeys, setupConfigBundles } from '../post-deploy-config-bundles.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { @@ -487,3 +488,164 @@ describe('setupConfigBundles', () => { }); }); }); + +// ── 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 index 2bd616857..607a895d5 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts @@ -9,7 +9,6 @@ const { mockCreateHttpGatewayTarget, mockDeleteHttpGateway, mockDeleteHttpGatewayTarget, - mockGetHttpGatewayTarget, mockListAllHttpGateways, mockListHttpGatewayTargets, mockWaitForGatewayReady, @@ -22,7 +21,6 @@ const { mockCreateHttpGatewayTarget: vi.fn(), mockDeleteHttpGateway: vi.fn(), mockDeleteHttpGatewayTarget: vi.fn(), - mockGetHttpGatewayTarget: vi.fn(), mockListAllHttpGateways: vi.fn(), mockListHttpGatewayTargets: vi.fn(), mockWaitForGatewayReady: vi.fn(), @@ -37,7 +35,6 @@ vi.mock('../../../aws/agentcore-http-gateways', () => ({ createHttpGatewayTarget: mockCreateHttpGatewayTarget, deleteHttpGateway: mockDeleteHttpGateway, deleteHttpGatewayTarget: mockDeleteHttpGatewayTarget, - getHttpGatewayTarget: mockGetHttpGatewayTarget, listAllHttpGateways: mockListAllHttpGateways, listHttpGatewayTargets: mockListHttpGatewayTargets, waitForGatewayReady: mockWaitForGatewayReady, @@ -131,7 +128,6 @@ describe('setupHttpGateways', () => { mockListHttpGatewayTargets.mockResolvedValue({ targets: [] }); mockWaitForGatewayReady.mockResolvedValue({ gatewayId: 'gw-001', status: 'READY' }); mockWaitForTargetReady.mockResolvedValue({}); - mockGetHttpGatewayTarget.mockRejectedValue(new Error('(404) Not Found')); // Default: no existing delivery sources, and PutDeliveryDestination returns an ARN mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { @@ -452,7 +448,7 @@ describe('setupHttpGateways', () => { }); describe('trace delivery rollback (Problem 1)', () => { - it('waits for target deletion before deleting gateway on trace delivery failure', async () => { + it('deletes target before gateway on trace delivery failure', async () => { mockCreateHttpGateway.mockResolvedValue({ gatewayId: 'gw-trace-fail', gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-trace-fail', @@ -470,21 +466,11 @@ describe('setupHttpGateways', () => { return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); }); - // Target still exists on first poll, gone on second — exercises the retry loop const callOrder: string[] = []; - let getTargetCallCount = 0; mockDeleteHttpGatewayTarget.mockImplementation(() => { callOrder.push('deleteTarget'); return Promise.resolve({ success: true }); }); - mockGetHttpGatewayTarget.mockImplementation(() => { - getTargetCallCount++; - callOrder.push(`getTarget(${getTargetCallCount})`); - if (getTargetCallCount === 1) { - return Promise.resolve({ targetId: 'tgt-trace-fail', status: 'DELETING' }); - } - return Promise.reject(new Error('(404) Not Found')); - }); mockDeleteHttpGateway.mockImplementation(() => { callOrder.push('deleteGateway'); return Promise.resolve({ success: true }); @@ -501,45 +487,10 @@ describe('setupHttpGateways', () => { expect(result.results[0]!.error).toContain('Trace delivery failed'); expect(result.results[0]!.error).toContain('AccessDenied'); - // Verify: delete target → poll (still exists) → poll (404) → delete gateway - expect(callOrder).toEqual(['deleteTarget', 'getTarget(1)', 'getTarget(2)', 'deleteGateway']); + // deleteHttpGatewayTarget now waits internally, so just verify ordering + expect(callOrder).toEqual(['deleteTarget', 'deleteGateway']); }); - it('proceeds to delete gateway even when target deletion poll times out', async () => { - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-timeout', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-timeout', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-timeout' }); - - // Make trace delivery fail - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ deliverySources: [] }); - } - if (cmd.constructor.name === 'PutDeliverySourceCommand') { - return Promise.reject(new Error('AccessDenied')); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); - }); - - // Target always exists (never deletes) — will trigger timeout - mockDeleteHttpGatewayTarget.mockResolvedValue({ success: true }); - mockGetHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-timeout', status: 'DELETING' }); - mockDeleteHttpGateway.mockResolvedValue({ success: true }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: sampleDeployedResources, - }); - - // Should still attempt gateway delete after timeout (best-effort) - expect(result.hasErrors).toBe(true); - expect(mockDeleteHttpGateway).toHaveBeenCalled(); - }, 120_000); - it('rollback cleans up auto-created role on trace delivery failure', async () => { const gwWithoutRole = { ...sampleHttpGateway, roleArn: undefined }; mockCreateHttpGateway.mockResolvedValue({ diff --git a/src/cli/operations/deploy/post-deploy-ab-tests.ts b/src/cli/operations/deploy/post-deploy-ab-tests.ts index 7c205c36e..e98971fc7 100644 --- a/src/cli/operations/deploy/post-deploy-ab-tests.ts +++ b/src/cli/operations/deploy/post-deploy-ab-tests.ts @@ -1,6 +1,6 @@ import type { ABTestDeployedState, AgentCoreProjectSpec, DeployedResourceState } from '../../../schema'; import { getCredentialProvider } from '../../aws/account'; -import { createABTest, deleteABTest, listABTests } from '../../aws/agentcore-ab-tests'; +import { createABTest, deleteABTest, getABTest, listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; import type { ABTestEvaluationConfig, ABTestVariant, TrafficAllocationConfig } from '../../aws/agentcore-ab-tests'; import { CreateRoleCommand, @@ -30,6 +30,7 @@ export interface ABTestSetupResult { abTestId?: string; abTestArn?: string; error?: string; + warning?: string; } export interface SetupABTestsResult { @@ -191,6 +192,30 @@ export async function deleteOrphanedABTests(options: { 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, @@ -204,6 +229,11 @@ export async function deleteOrphanedABTests(options: { 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({ @@ -412,7 +442,7 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< // Handle retry after a previous failed deploy left the role behind const errName = (err as { name?: string }).name; if (errName === 'EntityAlreadyExistsException') { - console.log(`IAM role "${roleName}" already exists — reusing it`); + // IAM role already exists — reuse it const existing = await iamClient.send(new GetRoleCommand({ RoleName: roleName })); roleArn = existing.Role?.Arn ?? ''; if (!roleArn) { @@ -501,7 +531,7 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< ); if (needsPropagationWait) { - console.log('Waiting for IAM role propagation (~15s)...'); + // Wait for IAM role propagation before creating the AB test await new Promise(resolve => setTimeout(resolve, 15_000)); } diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts index 113a3c420..72c8752ba 100644 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -1,4 +1,4 @@ -import type { AgentCoreProjectSpec, ConfigBundleDeployedState } from '../../../schema'; +import type { AgentCoreProjectSpec, ConfigBundleDeployedState, DeployedState } from '../../../schema'; import { createConfigurationBundle, deleteConfigurationBundle, @@ -285,3 +285,64 @@ function deepEqual(a: unknown, b: unknown): boolean { 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 index 4a4eb1e49..9aa348805 100644 --- a/src/cli/operations/deploy/post-deploy-http-gateways.ts +++ b/src/cli/operations/deploy/post-deploy-http-gateways.ts @@ -5,7 +5,6 @@ import { createHttpGatewayTarget, deleteHttpGateway, deleteHttpGatewayTarget, - getHttpGatewayTarget, listAllHttpGateways, listHttpGatewayTargets, waitForGatewayReady, @@ -185,7 +184,6 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom try { if (targetId) { await deleteHttpGatewayTarget({ region, gatewayId: createResult.gatewayId, targetId }); - await waitForTargetDeletion({ region, gatewayId: createResult.gatewayId, targetId }); } } catch { // Best-effort target cleanup @@ -222,11 +220,10 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom gatewayArn: createResult.gatewayArn, }); } catch (traceErr) { - // Rollback: delete target (and wait for deletion), then gateway, then role + // Rollback: delete target, then gateway, then role try { if (targetId) { await deleteHttpGatewayTarget({ region, gatewayId: createResult.gatewayId, targetId }); - await waitForTargetDeletion({ region, gatewayId: createResult.gatewayId, targetId }); } } catch { // Best-effort target cleanup @@ -310,19 +307,14 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom } for (const targetId of targetIds) { - const targetDeleteResult = await deleteHttpGatewayTarget({ + await deleteHttpGatewayTarget({ region, gatewayId: gwState.gatewayId, targetId, }); - if (!targetDeleteResult.success) { - console.warn( - `Warning: Failed to delete target "${targetId}" for orphaned gateway "${gwName}": ${targetDeleteResult.error}. Proceeding with best-effort gateway deletion.` - ); - } } - // Delete gateway (best-effort even if target deletion failed) + // Delete gateway after all targets are fully deleted const deleteResult = await deleteHttpGateway({ region, gatewayId: gwState.gatewayId, @@ -450,43 +442,6 @@ async function ensureTraceDelivery(options: { } } -/** - * Wait for a gateway target to be fully deleted before deleting the gateway. - * Polls getHttpGatewayTarget until it returns 404 or timeout is reached. - */ -async function waitForTargetDeletion(options: { - region: string; - gatewayId: string; - targetId: string; - timeoutMs?: number; -}): Promise { - const timeoutMs = options.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; // Target confirmed deleted - } - // Transient error — keep polling rather than assuming deleted - } - - 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); - } -} - // ============================================================================ // Helpers // ============================================================================ diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts index 00a50984f..5672e3601 100644 --- a/src/cli/primitives/ABTestPrimitive.ts +++ b/src/cli/primitives/ABTestPrimitive.ts @@ -159,7 +159,7 @@ export class ABTestPrimitive extends BasePrimitive', 'AB test name') .option('--description ', 'AB test description') - .option('--agent ', 'Agent to A/B test') + .option('--runtime ', 'Runtime agent to A/B test') .option('--role-arn ', 'IAM role ARN for the AB test (auto-created if not provided)') .option('--control-bundle ', 'Control variant config bundle name or ARN') .option('--control-version ', 'Control variant config bundle version') @@ -167,8 +167,8 @@ export class ABTestPrimitive extends BasePrimitive', 'Treatment variant config bundle version') .option('--control-weight ', 'Traffic weight for control (1-100)', parseInt) .option('--treatment-weight ', 'Traffic weight for treatment (1-100)', parseInt) - .option('--gateway ', 'Use an existing HTTP gateway (skips auto-creation and --agent)') - .option('--online-eval ', 'Online evaluation config name or ARN') + .option('--gateway ', 'Use an existing HTTP gateway (skips auto-creation and --runtime)') + .option('--online-eval ', 'Online evaluation config name (resolved from project)') .option('--traffic-header ', 'Header name for traffic routing') // TODO(post-preview): Re-enable --max-duration once configurable duration is launched. // .option('--max-duration ', 'Maximum duration in days (1-90)', parseInt) @@ -178,7 +178,7 @@ export class ABTestPrimitive extends BasePrimitive s.resourceArns?.some(a => a.includes(gatewayId)) && s.logType === 'TRACES' - ); - const delivery = source ? (deliveries.deliveries ?? []).find(d => d.deliverySourceName === source.name) : undefined; + const traceSources = sources.filter(s => s.logType === 'TRACES'); + + const source = traceSources.find(s => s.resourceArns?.some(a => a.includes(gatewayId))); + const delivery = source ? deliveries.find(d => d.deliverySourceName === source.name) : undefined; const hasSource = !!source; const hasDelivery = !!delivery; @@ -537,3 +538,25 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr ); } + +async function paginateDeliverySources(client: CloudWatchLogsClient): Promise { + const all: DeliverySource[] = []; + let nextToken: string | undefined; + do { + const resp = await client.send(new DescribeDeliverySourcesCommand({ nextToken })); + all.push(...(resp.deliverySources ?? [])); + nextToken = resp.nextToken; + } while (nextToken); + return all; +} + +async function paginateDeliveries(client: CloudWatchLogsClient): Promise { + const all: Delivery[] = []; + let nextToken: string | undefined; + do { + const resp = await client.send(new DescribeDeliveriesCommand({ nextToken })); + all.push(...(resp.deliveries ?? [])); + nextToken = resp.nextToken; + } while (nextToken); + return all; +} diff --git a/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx b/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx index e9835b1ca..9d47e4441 100644 --- a/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx +++ b/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx @@ -27,7 +27,10 @@ export function ABTestPickerScreen({ onExit }: ABTestPickerScreenProps) { const load = async () => { try { const configIO = new ConfigIO(); - const deployedState = await configIO.readDeployedState(); + 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; @@ -38,7 +41,7 @@ export function ABTestPickerScreen({ onExit }: ABTestPickerScreenProps) { } } setTests(found); - setRegion(process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'); + if (targets.length > 0) setRegion(targets[0]!.region); } catch { setTests([]); } diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 00d742666..6ba657551 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -16,7 +16,10 @@ 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 { setupConfigBundles } from '../../../operations/deploy/post-deploy-config-bundles'; +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 { @@ -346,10 +349,12 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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: ctx.projectSpec, + projectSpec: resolvedProjectSpec, existingBundles: existingConfigBundles, }); @@ -399,6 +404,14 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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); From 7a1384bdca5ba530550e9e4cfa1f0327f91c4e55 Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Mon, 20 Apr 2026 15:43:41 -0400 Subject: [PATCH 46/64] feat: migrate AB test API path from /abtests to /ab-tests with fallback (#94) Uses /ab-tests (new path) as primary, falls back to /abtests (legacy) on 404 for backwards compatibility during the API migration. --- .../aws/__tests__/agentcore-ab-tests.test.ts | 24 ++++++----- src/cli/aws/agentcore-ab-tests.ts | 43 ++++++++++++++----- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/cli/aws/__tests__/agentcore-ab-tests.test.ts b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts index 836d0e1b6..1c524926a 100644 --- a/src/cli/aws/__tests__/agentcore-ab-tests.test.ts +++ b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts @@ -45,7 +45,7 @@ describe('agentcore-ab-tests', () => { }); describe('createABTest', () => { - it('sends POST to /abtests with correct body', async () => { + it('sends POST to /ab-tests with correct body', async () => { mockFetch.mockResolvedValue( mockJsonResponse({ abTestId: 'abt-001', @@ -79,7 +79,7 @@ describe('agentcore-ab-tests', () => { expect(result.abTestId).toBe('abt-001'); expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/abtests'), + expect.stringContaining('/ab-tests'), expect.objectContaining({ method: 'POST' }) ); @@ -170,7 +170,7 @@ describe('agentcore-ab-tests', () => { }); describe('getABTest', () => { - it('sends GET to /abtests/{id}', async () => { + it('sends GET to /ab-tests/{id}', async () => { mockFetch.mockResolvedValue( mockJsonResponse({ abTestId: 'abt-123', @@ -196,14 +196,14 @@ describe('agentcore-ab-tests', () => { expect(result.abTestId).toBe('abt-123'); expect(result.results).toBeDefined(); expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/abtests/abt-123'), + expect.stringContaining('/ab-tests/abt-123'), expect.objectContaining({ method: 'GET' }) ); }); }); describe('updateABTest', () => { - it('sends PUT to /abtests/{id} with only defined fields', async () => { + it('sends PUT to /ab-tests/{id} with only defined fields', async () => { mockFetch.mockResolvedValue( mockJsonResponse({ abTestId: 'abt-123', @@ -221,7 +221,7 @@ describe('agentcore-ab-tests', () => { }); expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/abtests/abt-123'), + expect.stringContaining('/ab-tests/abt-123'), expect.objectContaining({ method: 'PUT' }) ); @@ -262,19 +262,19 @@ describe('agentcore-ab-tests', () => { }); describe('deleteABTest', () => { - it('sends DELETE to /abtests/{id} and returns success', async () => { + 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('/abtests/abt-123'), + expect.stringContaining('/ab-tests/abt-123'), expect.objectContaining({ method: 'DELETE' }) ); }); - it('returns error on failure instead of throwing', async () => { + it('falls back to legacy path on 404 then returns error if both fail', async () => { mockFetch.mockResolvedValue({ ok: false, status: 404, @@ -284,6 +284,10 @@ describe('agentcore-ab-tests', () => { const result = await deleteABTest({ region: 'us-east-1', abTestId: 'abt-999' }); + // First call: /ab-tests/abt-999 (new path), second call: /abtests/abt-999 (legacy fallback) + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]![0]).toContain('/ab-tests/abt-999'); + expect(mockFetch.mock.calls[1]![0]).toContain('/abtests/abt-999'); expect(result.success).toBe(false); expect(result.error).toContain('ABTest API error (404)'); }); @@ -299,7 +303,7 @@ describe('agentcore-ab-tests', () => { }); describe('listABTests', () => { - it('sends GET to /abtests', async () => { + it('sends GET to /ab-tests', async () => { mockFetch.mockResolvedValue( mockJsonResponse({ abTests: [ diff --git a/src/cli/aws/agentcore-ab-tests.ts b/src/cli/aws/agentcore-ab-tests.ts index 6bef70e3f..7d018c6c3 100644 --- a/src/cli/aws/agentcore-ab-tests.ts +++ b/src/cli/aws/agentcore-ab-tests.ts @@ -254,6 +254,29 @@ async function signedRequestToEndpoint( return response.json(); } +/** + * Makes a data plane request with path fallback for the AB test API migration. + * Tries the new `/ab-tests` path first; if a 404 is returned, retries with + * the legacy `/abtests` path. + */ +async function dpRequestWithFallback(options: { + region: string; + method: string; + path: string; + body?: string; +}): Promise { + try { + return await dpRequest(options); + } catch (err) { + // If the new path returns 404, fall back to the old `/abtests` path + if (err instanceof Error && err.message.includes('(404)')) { + const legacyPath = options.path.replace('/ab-tests', '/abtests'); // old path: /abtests + return dpRequest({ ...options, path: legacyPath }); + } + throw err; + } +} + /** Control plane request — kept for future use. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async function cpRequest(options: { region: string; method: string; path: string; body?: string }): Promise { @@ -283,10 +306,10 @@ export async function createABTest(options: CreateABTestOptions): Promise { // Data plane includes results/metrics in the response - const data = await dpRequest({ + const data = await dpRequestWithFallback({ region: options.region, method: 'GET', - path: `/abtests/${options.abTestId}`, + path: `/ab-tests/${options.abTestId}`, // new path; falls back to /abtests/{id} (legacy) on 404 }); return data as GetABTestResult; @@ -315,10 +338,10 @@ export async function updateABTest(options: UpdateABTestOptions): Promise { try { - await dpRequest({ + await dpRequestWithFallback({ region: options.region, method: 'DELETE', - path: `/abtests/${options.abTestId}`, + path: `/ab-tests/${options.abTestId}`, // new path; falls back to /abtests/{id} (legacy) on 404 }); return { success: true }; } catch (err) { @@ -344,10 +367,10 @@ export async function listABTests(options: ListABTestsOptions): Promise Date: Mon, 27 Apr 2026 16:00:38 -0400 Subject: [PATCH 47/64] fix: deploy warning banner, batch eval poll, (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: show yellow warning banner when post-deploy sub-resources fail Deploy banner now has three states instead of two: - Green "Deploy to AWS Complete" — everything succeeded - Yellow "Deploy to AWS Complete (with warnings)" — infra deployed but post-deploy resources (AB tests, config bundles, HTTP gateways) had errors - Red "Deploy to AWS Failed" — CDK stack deployment failed CLI non-interactive path returns exit code 2 for post-deploy warnings (vs exit 0 for success, exit 1 for infra failure) so CI/CD pipelines can differentiate. Post-deploy errors (AB tests, config bundles, HTTP gateways, online evals) are shown inside the yellow banner box and in the post-deploy warnings section below. The deploy step stays marked as success since the CDK stack did deploy correctly. * fix: treat COMPLETED_WITH_ERRORS as terminal in batch evaluation poll loop The batch evaluation poll loop only recognized COMPLETED, FAILED, STOPPED, and CANCELLED as terminal statuses. When the service returned COMPLETED_WITH_ERRORS (typical when any session fails), the CLI never exited the poll loop and hung for 67 minutes until the fetch timed out. Add COMPLETED_WITH_ERRORS to TERMINAL_STATUSES so the poll exits immediately. The status is still treated as a non-success outcome (line 227 checks for COMPLETED specifically), so partial failures are reported correctly. --- src/cli/commands/deploy/actions.ts | 18 +++++-- src/cli/commands/deploy/command.tsx | 10 +++- src/cli/commands/deploy/types.ts | 1 + .../operations/eval/run-batch-evaluation.ts | 2 +- src/cli/tui/components/DeployStatus.tsx | 40 +++++++++++---- .../__tests__/DeployStatus.test.tsx | 49 +++++++++++++++++++ src/cli/tui/screens/deploy/DeployScreen.tsx | 9 +++- src/cli/tui/screens/deploy/useDeployFlow.ts | 14 ++++++ 8 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 655ec057c..be330ae0f 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -453,6 +453,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 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}`)); } } @@ -485,11 +487,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 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'); + if (r.warning) { + logger.log(r.warning, 'warn'); + postDeployWarnings.push(r.warning); + } } // Update deployed state to remove deleted AB tests @@ -532,7 +538,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error'); const errorMessages = errors.map(err => `"${err.gatewayName}": ${err.error}`).join('; '); - throw new Error(`HTTP gateway setup failed: ${errorMessages}`); + logger.log(`HTTP gateway setup warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `HTTP gateway "${err.gatewayName}": ${err.error}`)); } } @@ -563,7 +570,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error'); const errorMessages = errors.map(err => `"${err.bundleName}": ${err.error}`).join('; '); - throw new Error(`Config bundle setup failed: ${errorMessages}`); + logger.log(`Config bundle setup warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `Config bundle "${err.bundleName}": ${err.error}`)); } } @@ -592,7 +600,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error'); const errorMessages = errors.map(err => `"${err.testName}": ${err.error}`).join('; '); - throw new Error(`AB test setup failed: ${errorMessages}`); + logger.log(`AB test setup warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `AB test "${err.testName}": ${err.error}`)); } } @@ -629,6 +638,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? postDeployWarnings : undefined, }; } catch (err: unknown) { logger.log(getErrorMessage(err), 'error'); diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index 58fe8b254..5307ce3eb 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -103,6 +103,13 @@ async function handleDeployCLI(options: DeployOptions): 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/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index 4e13842b5..c8ddf199a 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -74,7 +74,7 @@ export interface RunBatchEvaluationCommandResult { // ============================================================================ const DEFAULT_POLL_INTERVAL_MS = 10_000; -const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'STOPPED', 'CANCELLED']); +const TERMINAL_STATUSES = new Set(['COMPLETED', 'COMPLETED_WITH_ERRORS', 'FAILED', 'STOPPED', 'CANCELLED']); // ============================================================================ // Implementation 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/__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/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 3ce7c0ef5..405c356a3 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -77,6 +77,7 @@ export function DeployScreen({ numStacksWithChanges, deployNotes, postDeployWarnings, + postDeployHasError, isDiffLoading, requestDiff, hasError, @@ -330,7 +331,13 @@ export function DeployScreen({ {/* Show deploy status when deploying or complete */} {showDeployStatus && ( - + )} diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 6ba657551..ed72eae3e 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -92,6 +92,8 @@ interface DeployFlowState { 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) */ @@ -139,6 +141,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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([]); @@ -333,6 +336,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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}`), @@ -341,6 +345,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } 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}`]); } } @@ -373,6 +378,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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}`), @@ -381,6 +387,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } 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}`]); } } @@ -401,6 +408,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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}`)]); } @@ -427,6 +435,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } 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}`]); } } @@ -459,6 +468,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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}`), @@ -467,6 +477,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } 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}`]); } } @@ -498,11 +509,13 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState 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}`]); } } @@ -844,6 +857,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState numStacksWithChanges, deployNotes, postDeployWarnings, + postDeployHasError, isDiffLoading, requestDiff, stackOutputs, From 335dd1a1474abed7795fed33bb889ed32ace77a7 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:24:38 -0400 Subject: [PATCH 48/64] fix: config bundle name resolution and add create-branch command (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: config bundle name resolution and add create-branch command Accept local bundle names (from agentcore.json) in CLI and TUI when the API stores them with a project-name prefix. The resolver now tries the exact name, current prefix (projectBundle), and legacy underscore prefix (project_Bundle) for backward compatibility. Also adds `agentcore cb create-branch` to create a new branch on an existing bundle via the update API, instead of requiring a whole new bundle to be created. * fix: address PR review — DRY name variants, pagination, sort, and tests - Extract getBundleNameVariants to shared utility, use in both resolve-bundle.ts and useConfigBundleHub.ts - Paginate listConfigurationBundles in resolveBundleByName so bundles beyond page 1 are found - Sort versions by versionCreatedAt descending in create-branch to reliably pick the latest version as branch parent - Add unit tests for getBundleNameVariants and resolveBundleByName (9 tests covering fast path, fallback, pagination, legacy names) --- src/cli/commands/config-bundle/command.tsx | 91 +++++++++++++++- .../__tests__/bundle-name-variants.test.ts | 22 ++++ .../__tests__/resolve-bundle.test.ts | 103 ++++++++++++++++++ .../config-bundle/bundle-name-variants.ts | 11 ++ .../config-bundle/resolve-bundle.ts | 15 ++- .../post-deploy-config-bundles.test.ts | 4 +- .../config-bundle-hub/useConfigBundleHub.ts | 4 +- 7 files changed, 241 insertions(+), 9 deletions(-) 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 diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx index c7dd746ee..fdbe29013 100644 --- a/src/cli/commands/config-bundle/command.tsx +++ b/src/cli/commands/config-bundle/command.tsx @@ -1,4 +1,8 @@ -import { getConfigurationBundleVersion, listConfigurationBundleVersions } from '../../aws/agentcore-config-bundles'; +import { + getConfigurationBundleVersion, + listConfigurationBundleVersions, + updateConfigurationBundle, +} from '../../aws/agentcore-config-bundles'; import type { ConfigurationBundleVersionSummary, ListConfigurationBundleVersionsFilter, @@ -254,5 +258,90 @@ export const registerConfigBundle = (program: Command) => { } }); + // --- 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/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/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts index b825ed914..964c705c1 100644 --- a/src/cli/operations/config-bundle/resolve-bundle.ts +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -6,6 +6,7 @@ */ import { ConfigIO } from '../../../lib'; import { listConfigurationBundleVersions, listConfigurationBundles } from '../../aws/agentcore-config-bundles'; +import { getBundleNameVariants } from './bundle-name-variants'; export interface ResolvedBundle { bundleId: string; @@ -60,11 +61,15 @@ export async function resolveBundleByName( // Project spec may not be available } - const result = await listConfigurationBundles({ region, maxResults: 100 }); - const prefixedName = projectName ? `${projectName}${bundleName}` : undefined; - const match = result.bundles.find( - b => b.bundleName === bundleName || (prefixedName && b.bundleName === prefixedName) - ); + 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?`); } 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 index d0c513aa0..ecfc285cd 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -57,7 +57,7 @@ describe('setupConfigBundles', () => { expect(mockCreateConfigurationBundle).toHaveBeenCalledWith( expect.objectContaining({ region: REGION, - bundleName: 'TestProject_MyBundle', + bundleName: 'TestProjectMyBundle', components: { foo: { type: 'inline', value: 'bar' } }, commitMessage: 'Create MyBundle', }) @@ -350,7 +350,7 @@ describe('setupConfigBundles', () => { }); mockListConfigurationBundles.mockResolvedValue({ - bundles: [{ bundleId: 'b-found', bundleName: 'TestProject_MyBundle' }], + bundles: [{ bundleId: 'b-found', bundleName: 'TestProjectMyBundle' }], }); mockListConfigurationBundleVersions.mockResolvedValue({ diff --git a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts index e886b17b5..c0276ae53 100644 --- a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts +++ b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts @@ -8,6 +8,7 @@ import { 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 { @@ -118,7 +119,8 @@ export function useConfigBundleHub(): ConfigBundleHubState { // Stale deployed-state ID — try to resolve via list API try { const allBundles = await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 }); - const match = allBundles.bundles.find(b => b.bundleName === bundleSpec.name); + 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; From 300a5168bdc33b19dcd8182613d9e89dc85e4ce0 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:27:18 -0400 Subject: [PATCH 49/64] fix: migrate batch evaluation API client to latest dataplane schema (#142) --- src/cli/aws/agentcore-batch-evaluation.ts | 352 ++++++++++++++---- src/cli/aws/agentcore-recommendation.ts | 2 - src/cli/commands/pause/command.tsx | 4 +- src/cli/commands/run/command.tsx | 16 +- src/cli/commands/stop/command.tsx | 4 +- src/cli/operations/eval/batch-eval-storage.ts | 12 +- .../operations/eval/run-batch-evaluation.ts | 57 ++- .../__tests__/apply-to-bundle.test.ts | 2 +- .../__tests__/recommendation-storage.test.ts | 5 +- .../recommendation/RecommendationFlow.tsx | 6 - .../RecommendationHistoryScreen.tsx | 6 - .../run-eval/BatchEvalHistoryScreen.tsx | 12 +- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 24 +- 13 files changed, 344 insertions(+), 158 deletions(-) diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index d140dc138..965a5e91b 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -5,12 +5,18 @@ * Each batch evaluation is started, polled, and optionally stopped. * * Endpoints: - * POST /evaluations/batch-evaluate → StartBatchEvaluation - * GET /evaluations/batch-evaluate/{id} → GetBatchEvaluation - * GET /evaluations/batch-evaluate → ListBatchEvaluations - * POST /evaluations/batch-evaluate/{id}/stop → StopBatchEvaluation + * 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). + * + * LEGACY FALLBACK: The API is migrating from an old schema to a new one. + * Each operation tries the new schema first, then falls back to the legacy + * schema on error. Search for "LEGACY FALLBACK" to find all fallback code + * to remove once the migration is complete. */ import { getCredentialProvider } from './account'; import { Sha256 } from '@aws-crypto/sha256-js'; @@ -19,7 +25,7 @@ import { HttpRequest } from '@smithy/protocol-http'; import { SignatureV4 } from '@smithy/signature-v4'; // ============================================================================ -// Types +// Types (new schema) // ============================================================================ export interface SessionFilterConfig { @@ -27,19 +33,24 @@ export interface SessionFilterConfig { endTime?: string; } -export interface CloudWatchSessionInput { +export interface CloudWatchFilterConfig { sessionIds?: string[]; - sessionFilterConfig?: SessionFilterConfig; + timeRange?: SessionFilterConfig; } -export interface CloudWatchSource { +export interface CloudWatchLogsSource { serviceNames: string[]; logGroupNames: string[]; - sessionInput?: CloudWatchSessionInput; + filterConfig?: CloudWatchFilterConfig; +} + +export interface DataSourceConfig { + cloudWatchLogs?: CloudWatchLogsSource; + onlineEvaluationConfigSource?: Record; } -export interface BatchEvaluationConfig { - evaluators: { evaluatorId: string }[]; +export interface Evaluator { + evaluatorId: string; } export interface GroundTruthAssertion { @@ -77,22 +88,28 @@ export interface SessionMetadataEntry { sessionId: string; testScenarioId?: string; groundTruth?: GroundTruth; + metadata?: Record; +} + +export interface EvaluationMetadata { + sessionMetadata?: SessionMetadataEntry[]; } export interface StartBatchEvaluationOptions { region: string; name: string; - evaluationConfig: BatchEvaluationConfig; - sessionSource: { - cloudWatchSource: CloudWatchSource; - }; - sessionMetadata?: SessionMetadataEntry[]; + evaluators: Evaluator[]; + dataSourceConfig: DataSourceConfig; + evaluationMetadata?: EvaluationMetadata; + description?: string; + tags?: Record; executionRoleArn?: string; clientToken?: string; } export interface StartBatchEvaluationResult { - batchEvaluateId: string; + batchEvaluationId: string; + batchEvaluationArn: string; name: string; status: string; createdAt?: string; @@ -100,37 +117,16 @@ export interface StartBatchEvaluationResult { export interface GetBatchEvaluationOptions { region: string; - batchEvaluateId: string; + batchEvaluationId: string; } -export interface GetBatchEvaluationResult { - batchEvaluateId: string; - name: string; - status: string; - createdAt?: string; - updatedAt?: string; - evaluationConfig?: BatchEvaluationConfig; - sessionSource?: { - cloudWatchSource?: CloudWatchSource; - }; - outputDataConfig?: { - cloudWatchDestination?: { - logGroupName: string; - logStreamName: string; - }; - }; - evaluationResults?: EvaluationResults; - results?: BatchEvaluationResultEntry[]; - errorDetails?: string[]; - statusReasons?: string[]; +export interface CloudWatchOutputConfig { + logGroupName: string; + logStreamName: string; } -export interface BatchEvaluationResultEntry { - evaluatorId: string; - score?: number; - label?: string; - explanation?: string; - error?: string; +export interface OutputConfig { + cloudWatchConfig?: CloudWatchOutputConfig; } export interface EvaluatorSummary { @@ -149,10 +145,35 @@ export interface EvaluatorSummary { export interface EvaluationResults { evaluatorSummaries?: EvaluatorSummary[]; - sessionsCompleted?: number; - sessionsFailed?: number; - sessionsInProgress?: number; - totalSessions?: number; + 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; + tags?: Record; +} + +export interface BatchEvaluationResultEntry { + evaluatorId: string; + score?: number; + label?: string; + explanation?: string; + error?: string; } export interface ListBatchEvaluationsOptions { @@ -162,11 +183,15 @@ export interface ListBatchEvaluationsOptions { } export interface BatchEvaluationSummary { - batchEvaluateId: string; + batchEvaluationId: string; + batchEvaluationArn: string; name: string; status: string; createdAt?: string; - updatedAt?: string; + description?: string; + evaluators?: Evaluator[]; + evaluationResults?: EvaluationResults; + errorDetails?: string[]; } export interface ListBatchEvaluationsResult { @@ -176,11 +201,24 @@ export interface ListBatchEvaluationsResult { export interface StopBatchEvaluationOptions { region: string; - batchEvaluateId: string; + batchEvaluationId: string; } export interface StopBatchEvaluationResult { - batchEvaluateId: string; + batchEvaluationId: string; + batchEvaluationArn: string; + status: string; + description?: string; +} + +export interface DeleteBatchEvaluationOptions { + region: string; + batchEvaluationId: string; +} + +export interface DeleteBatchEvaluationResult { + batchEvaluationId: string; + batchEvaluationArn: string; status: string; } @@ -242,6 +280,140 @@ async function signedRequest(options: { return { data: await response.json(), status: response.status }; } +// ============================================================================ +// LEGACY FALLBACK — remove this entire section when API migration is complete +// +// The API is transitioning from an old schema to a new one. These helpers +// convert between the two so the CLI works against both old and new backends. +// +// Old schema differences: +// - Request: sessionSource.cloudWatchSource → dataSourceConfig.cloudWatchLogs +// - Request: cloudWatchSource.sessionInput → cloudWatchLogs.filterConfig +// - Request: sessionInput.sessionFilterConfig → filterConfig.timeRange +// - Request: evaluationConfig: { evaluators } → evaluators (top-level) +// - Request: sessionMetadata (top-level) → evaluationMetadata.sessionMetadata +// - Response: batchEvaluateId → batchEvaluationId +// - Response: outputDataConfig.cloudWatchDestination → outputConfig.cloudWatchConfig +// - Response: sessionsCompleted → numberOfSessionsCompleted (etc.) +// ============================================================================ + +function isLegacyFallbackError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + return err.message.includes('(400)') || err.message.includes('(422)'); +} + +function toLegacyStartBody(options: StartBatchEvaluationOptions): string { + const cw = options.dataSourceConfig.cloudWatchLogs; + const legacySessionInput = cw?.filterConfig + ? { + ...(cw.filterConfig.sessionIds ? { sessionIds: cw.filterConfig.sessionIds } : {}), + ...(cw.filterConfig.timeRange ? { sessionFilterConfig: cw.filterConfig.timeRange } : {}), + } + : undefined; + + const body: Record = { + name: options.name, + evaluationConfig: { + evaluators: options.evaluators, + }, + sessionSource: { + cloudWatchSource: { + serviceNames: cw?.serviceNames ?? [], + logGroupNames: cw?.logGroupNames ?? [], + ...(legacySessionInput ? { sessionInput: legacySessionInput } : {}), + }, + }, + }; + + const sessionMetadata = options.evaluationMetadata?.sessionMetadata; + if (sessionMetadata && sessionMetadata.length > 0) { + body.sessionMetadata = sessionMetadata; + } + if (options.executionRoleArn) body.executionRoleArn = options.executionRoleArn; + if (options.clientToken) body.clientToken = options.clientToken; + + return JSON.stringify(body); +} + +function normalizeStartResult(raw: Record): StartBatchEvaluationResult { + return { + batchEvaluationId: (raw.batchEvaluationId ?? raw.batchEvaluateId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? raw.bundleArn ?? '') as string, + name: (raw.name ?? '') as string, + status: (raw.status ?? '') as string, + createdAt: raw.createdAt as string | undefined, + }; +} + +function normalizeGetResult(raw: Record): GetBatchEvaluationResult { + const id = (raw.batchEvaluationId ?? raw.batchEvaluateId ?? '') as string; + + // LEGACY FALLBACK: normalize outputDataConfig.cloudWatchDestination → outputConfig.cloudWatchConfig + let outputConfig = raw.outputConfig as OutputConfig | undefined; + if (!outputConfig) { + const legacyOutput = raw.outputDataConfig as Record | undefined; + const legacyCw = legacyOutput?.cloudWatchDestination as CloudWatchOutputConfig | undefined; + if (legacyCw) { + outputConfig = { cloudWatchConfig: legacyCw }; + } + } + + // LEGACY FALLBACK: normalize old evaluationResults field names + let evaluationResults = raw.evaluationResults as EvaluationResults | undefined; + if (evaluationResults) { + const er = evaluationResults as Record; + evaluationResults = { + evaluatorSummaries: (er.evaluatorSummaries ?? er.evaluatorSummaries) as EvaluatorSummary[] | undefined, + numberOfSessionsCompleted: (er.numberOfSessionsCompleted ?? er.sessionsCompleted) as number | undefined, + numberOfSessionsFailed: (er.numberOfSessionsFailed ?? er.sessionsFailed) as number | undefined, + numberOfSessionsInProgress: (er.numberOfSessionsInProgress ?? er.sessionsInProgress) as number | undefined, + totalNumberOfSessions: (er.totalNumberOfSessions ?? er.totalSessions) as number | undefined, + numberOfSessionsIgnored: er.numberOfSessionsIgnored as number | undefined, + }; + } + + return { + batchEvaluationId: id, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + name: (raw.name ?? '') 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, + evaluationResults, + errorDetails: (raw.errorDetails ?? raw.statusReasons) as string[] | undefined, + description: raw.description as string | undefined, + tags: raw.tags as Record | undefined, + }; +} + +function normalizeStopResult(raw: Record): StopBatchEvaluationResult { + return { + batchEvaluationId: (raw.batchEvaluationId ?? raw.batchEvaluateId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + status: (raw.status ?? '') as string, + description: raw.description as string | undefined, + }; +} + +function normalizeSummary(raw: Record): BatchEvaluationSummary { + return { + batchEvaluationId: (raw.batchEvaluationId ?? raw.batchEvaluateId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + name: (raw.name ?? '') as string, + status: (raw.status ?? '') as string, + createdAt: raw.createdAt as string | undefined, + description: raw.description as string | undefined, + evaluators: raw.evaluators as Evaluator[] | undefined, + + evaluationResults: raw.evaluationResults as EvaluationResults | undefined, + errorDetails: raw.errorDetails as string[] | undefined, + }; +} + // ============================================================================ // API Operations // ============================================================================ @@ -252,11 +424,17 @@ async function signedRequest(options: { export async function startBatchEvaluation(options: StartBatchEvaluationOptions): Promise { const body: Record = { name: options.name, - evaluationConfig: options.evaluationConfig, - sessionSource: options.sessionSource, + evaluators: options.evaluators, + dataSourceConfig: options.dataSourceConfig, }; - if (options.sessionMetadata && options.sessionMetadata.length > 0) { - body.sessionMetadata = options.sessionMetadata; + if (options.evaluationMetadata) { + body.evaluationMetadata = options.evaluationMetadata; + } + if (options.description) { + body.description = options.description; + } + if (options.tags) { + body.tags = options.tags; } if (options.executionRoleArn) { body.executionRoleArn = options.executionRoleArn; @@ -265,14 +443,28 @@ export async function startBatchEvaluation(options: StartBatchEvaluationOptions) body.clientToken = options.clientToken; } - const { data } = await signedRequest({ - region: options.region, - method: 'POST', - path: '/evaluations/batch-evaluate', - body: JSON.stringify(body), - }); - - return data as StartBatchEvaluationResult; + try { + const { data } = await signedRequest({ + region: options.region, + method: 'POST', + path: '/evaluations/batch-evaluate', + body: JSON.stringify(body), + }); + return normalizeStartResult(data as Record); + } catch (err) { + // LEGACY FALLBACK: if new schema rejected, retry with old schema + if (isLegacyFallbackError(err)) { + console.error('[batch-eval] New API schema rejected — retrying with legacy schema (temporary fallback)'); + const { data } = await signedRequest({ + region: options.region, + method: 'POST', + path: '/evaluations/batch-evaluate', + body: toLegacyStartBody(options), + }); + return normalizeStartResult(data as Record); + } + throw err; + } } /** @@ -282,10 +474,10 @@ export async function getBatchEvaluation(options: GetBatchEvaluationOptions): Pr const { data } = await signedRequest({ region: options.region, method: 'GET', - path: `/evaluations/batch-evaluate/${options.batchEvaluateId}`, + path: `/evaluations/batch-evaluate/${options.batchEvaluationId}`, }); - return data as GetBatchEvaluationResult; + return normalizeGetResult(data as Record); } /** @@ -305,9 +497,9 @@ export async function listBatchEvaluations(options: ListBatchEvaluationsOptions) path, }); - const result = data as ListBatchEvaluationsResult; + const result = data as { batchEvaluations?: Record[]; nextToken?: string }; return { - batchEvaluations: result.batchEvaluations ?? [], + batchEvaluations: (result.batchEvaluations ?? []).map(normalizeSummary), nextToken: result.nextToken, }; } @@ -319,10 +511,30 @@ export async function stopBatchEvaluation(options: StopBatchEvaluationOptions): const { data } = await signedRequest({ region: options.region, method: 'POST', - path: `/evaluations/batch-evaluate/${options.batchEvaluateId}/stop`, + path: `/evaluations/batch-evaluate/${options.batchEvaluationId}/stop`, }); - return data as StopBatchEvaluationResult; + return normalizeStopResult(data as Record); +} + +/** + * 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 ?? raw.batchEvaluateId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + status: (raw.status ?? '') as string, + }; } /** diff --git a/src/cli/aws/agentcore-recommendation.ts b/src/cli/aws/agentcore-recommendation.ts index ce459c90d..a2d049b89 100644 --- a/src/cli/aws/agentcore-recommendation.ts +++ b/src/cli/aws/agentcore-recommendation.ts @@ -110,7 +110,6 @@ export interface RecommendationResultConfigurationBundle { export interface SystemPromptRecommendationResult { recommendedSystemPrompt?: string; - explanation?: string; configurationBundle?: RecommendationResultConfigurationBundle; errorCode?: string; errorMessage?: string; @@ -119,7 +118,6 @@ export interface SystemPromptRecommendationResult { export interface ToolDescriptionRecommendationToolResult { toolName: string; recommendedToolDescription: string; - explanation: string; } export interface ToolDescriptionRecommendationResult { diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index d44da4bd6..d532f509e 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -217,14 +217,14 @@ export const registerStop = (program: Command) => { const result = await stopBatchEvaluation({ region, - batchEvaluateId: cliOptions.id, + batchEvaluationId: cliOptions.id, }); if (cliOptions.json) { console.log(JSON.stringify({ success: true, ...result })); } else { console.log(`\nBatch evaluation stopped successfully`); - console.log(`ID: ${result.batchEvaluateId}`); + console.log(`ID: ${result.batchEvaluationId}`); console.log(`Status: ${result.status}\n`); } diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index 8d71f45cd..b9a10d48f 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -440,9 +440,6 @@ export const registerRun = (program: Command) => { const toolResult = result.result.toolDescriptionRecommendationResult; if (sysResult) { - if (sysResult.explanation?.trim()) { - console.log(`\nWhat changed: ${sysResult.explanation}`); - } if (sysResult.recommendedSystemPrompt) { console.log('\n+++ Recommended System Prompt +++'); console.log(sysResult.recommendedSystemPrompt); @@ -450,9 +447,6 @@ export const registerRun = (program: Command) => { } else if (toolResult?.tools) { for (const tool of toolResult.tools) { console.log(`\nTool: ${tool.toolName}`); - if (tool.explanation?.trim()) { - console.log(`Explanation: ${tool.explanation}`); - } console.log(`Recommended: ${tool.recommendedToolDescription}`); } } @@ -499,17 +493,17 @@ export const registerRun = (program: Command) => { }; function formatBatchEvalOutput(result: RunBatchEvaluationCommandResult): void { - console.log(`\nBatch Evaluation: ${result.name ?? result.batchEvaluateId}`); - console.log(`ID: ${result.batchEvaluateId}`); + 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.totalSessions != null) parts.push(`${evalResults.totalSessions} sessions`); - if (evalResults.sessionsCompleted != null) parts.push(`${evalResults.sessionsCompleted} completed`); - if (evalResults.sessionsFailed) parts.push(`${evalResults.sessionsFailed} failed`); + 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(', ')}`); } diff --git a/src/cli/commands/stop/command.tsx b/src/cli/commands/stop/command.tsx index 9a440e52e..5bdf92638 100644 --- a/src/cli/commands/stop/command.tsx +++ b/src/cli/commands/stop/command.tsx @@ -22,14 +22,14 @@ export const registerStop = (program: Command) => { const result = await stopBatchEvaluation({ region, - batchEvaluateId: cliOptions.id, + batchEvaluationId: cliOptions.id, }); if (cliOptions.json) { console.log(JSON.stringify({ success: true, ...result })); } else { console.log(`\nBatch evaluation stopped successfully`); - console.log(`ID: ${result.batchEvaluateId}`); + console.log(`ID: ${result.batchEvaluationId}`); console.log(`Status: ${result.status}\n`); } diff --git a/src/cli/operations/eval/batch-eval-storage.ts b/src/cli/operations/eval/batch-eval-storage.ts index e8fd21872..3145120ba 100644 --- a/src/cli/operations/eval/batch-eval-storage.ts +++ b/src/cli/operations/eval/batch-eval-storage.ts @@ -8,7 +8,7 @@ const BATCH_EVAL_RESULTS_DIR = 'batch-eval-results'; export interface BatchEvalRunRecord { name: string; - batchEvaluateId: string; + batchEvaluationId: string; status: string; startedAt?: string; completedAt?: string; @@ -29,12 +29,12 @@ export function saveBatchEvalRun(result: RunBatchEvaluationCommandResult): strin const dir = getResultsDir(); mkdirSync(dir, { recursive: true }); - const id = result.batchEvaluateId ?? 'unknown'; + const id = result.batchEvaluationId ?? 'unknown'; const filePath = join(dir, `${id}.json`); const record: BatchEvalRunRecord = { name: result.name ?? 'unknown', - batchEvaluateId: id, + batchEvaluationId: id, status: result.status ?? 'unknown', startedAt: result.startedAt, completedAt: result.completedAt, @@ -47,13 +47,13 @@ export function saveBatchEvalRun(result: RunBatchEvaluationCommandResult): strin return filePath; } -export function loadBatchEvalRun(batchEvaluateId: string): BatchEvalRunRecord { +export function loadBatchEvalRun(batchEvaluationId: string): BatchEvalRunRecord { const dir = getResultsDir(); - const jsonName = batchEvaluateId.endsWith('.json') ? batchEvaluateId : `${batchEvaluateId}.json`; + const jsonName = batchEvaluationId.endsWith('.json') ? batchEvaluationId : `${batchEvaluationId}.json`; const filePath = join(dir, jsonName); if (!existsSync(filePath)) { - throw new Error(`Batch evaluation run "${batchEvaluateId}" not found at ${filePath}`); + throw new Error(`Batch evaluation run "${batchEvaluationId}" not found at ${filePath}`); } return JSON.parse(readFileSync(filePath, 'utf-8')) as BatchEvalRunRecord; diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index c8ddf199a..c56198079 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -1,7 +1,7 @@ /** * Orchestrates running a BatchEvaluation: * 1. Resolve agent from deployed state (for serviceNames / logGroupNames) - * 2. Build evaluationConfig + sessionSource + * 2. Build evaluators + dataSourceConfig * 3. Call StartBatchEvaluation * 4. Poll GetBatchEvaluation until terminal status * 5. Return results @@ -10,7 +10,7 @@ import { ConfigIO } from '../../../lib'; import type { DeployedState } from '../../../schema'; import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import type { - CloudWatchSessionInput, + CloudWatchFilterConfig, EvaluationResults, GetBatchEvaluationResult, SessionMetadataEntry, @@ -45,7 +45,7 @@ export interface RunBatchEvaluationOptions { /** Progress callback */ onProgress?: (status: string, message: string) => void; /** Called once the batch evaluation has been created, with ID and region for cancellation */ - onStarted?: (info: { batchEvaluateId: string; region: string }) => void; + onStarted?: (info: { batchEvaluationId: string; region: string }) => void; } export interface BatchEvaluationResult { @@ -59,7 +59,7 @@ export interface BatchEvaluationResult { export interface RunBatchEvaluationCommandResult { success: boolean; error?: string; - batchEvaluateId?: string; + batchEvaluationId?: string; name?: string; status?: string; results: BatchEvaluationResult[]; @@ -153,22 +153,22 @@ export async function runBatchEvaluationCommand( onProgress?.('starting', `Starting batch evaluation "${evalName}"...`); - // Build optional session input for CloudWatch filtering - // API requires either sessionIds OR sessionFilterConfig, not both — sessionIds takes precedence + // 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 sessionInput: CloudWatchSessionInput | undefined = (() => { + 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 { sessionFilterConfig: { startTime, endTime } }; + return { timeRange: { startTime, endTime } }; } return undefined; })(); @@ -176,18 +176,16 @@ export async function runBatchEvaluationCommand( const startPayload = { region, name: evalName, - evaluationConfig: { - evaluators: resolvedEvaluators.map(id => ({ evaluatorId: id })), - }, - sessionSource: { - cloudWatchSource: { + evaluators: resolvedEvaluators.map(id => ({ evaluatorId: id })), + dataSourceConfig: { + cloudWatchLogs: { serviceNames: [serviceName], logGroupNames: [runtimeLogGroup], - ...(sessionInput ? { sessionInput } : {}), + ...(filterConfig ? { filterConfig } : {}), }, }, ...(options.sessionMetadata && options.sessionMetadata.length > 0 - ? { sessionMetadata: options.sessionMetadata } + ? { evaluationMetadata: { sessionMetadata: options.sessionMetadata } } : {}), ...(options.executionRoleArn ? { executionRoleArn: options.executionRoleArn } : {}), clientToken: generateClientToken(), @@ -200,14 +198,15 @@ export async function runBatchEvaluationCommand( logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); logger?.endStep('success'); - onProgress?.('running', `Batch evaluation started (ID: ${startResult.batchEvaluateId})`); + onProgress?.('running', `Batch evaluation started (ID: ${startResult.batchEvaluationId})`); onProgress?.('running', 'This may take a few minutes...'); - options.onStarted?.({ batchEvaluateId: startResult.batchEvaluateId, region }); + options.onStarted?.({ batchEvaluationId: startResult.batchEvaluationId, region }); // 4. Poll for completion logger?.startStep('Poll for completion'); let current: GetBatchEvaluationResult = { - batchEvaluateId: startResult.batchEvaluateId, + batchEvaluationId: startResult.batchEvaluationId, + batchEvaluationArn: startResult.batchEvaluationArn, name: startResult.name, status: startResult.status, }; @@ -217,15 +216,15 @@ export async function runBatchEvaluationCommand( current = await getBatchEvaluation({ region, - batchEvaluateId: startResult.batchEvaluateId, + batchEvaluationId: startResult.batchEvaluationId, }); onProgress?.('polling', `Status: ${current.status}`); logger?.log(`Poll status: ${current.status}`); } - if (current.status !== 'COMPLETED') { - const reasons = current.statusReasons?.join('; ') ?? ''; + 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'); @@ -234,7 +233,7 @@ export async function runBatchEvaluationCommand( return { success: false, error, - batchEvaluateId: startResult.batchEvaluateId, + batchEvaluationId: startResult.batchEvaluationId, name: evalName, status: current.status, results: [], @@ -248,7 +247,7 @@ export async function runBatchEvaluationCommand( logger?.startStep('Fetch results'); let results: BatchEvaluationResult[] = []; - const cwDest = current.outputDataConfig?.cloudWatchDestination; + const cwDest = current.outputConfig?.cloudWatchConfig; if (cwDest) { try { results = await fetchResultsFromCloudWatch(region, cwDest.logGroupName, cwDest.logStreamName); @@ -258,16 +257,6 @@ export async function runBatchEvaluationCommand( } } - // Fall back to inline results if CW fetch returned nothing - if (results.length === 0 && current.results?.length) { - results = current.results.map(r => ({ - evaluatorId: r.evaluatorId, - score: r.score, - label: r.label, - explanation: r.explanation, - error: r.error, - })); - } logger?.endStep('success'); logger?.log(`Results: ${JSON.stringify(results, null, 2)}`); @@ -275,7 +264,7 @@ export async function runBatchEvaluationCommand( return { success: true, - batchEvaluateId: startResult.batchEvaluateId, + batchEvaluationId: startResult.batchEvaluationId, name: evalName, status: current.status, results, 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 56a70edc3..5e0fb668a 100644 --- a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts +++ b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts @@ -142,7 +142,7 @@ describe('applyRecommendationToBundle', () => { const result: RecommendationResult = { toolDescriptionRecommendationResult: { - tools: [{ toolName: 'search', recommendedToolDescription: 'new desc', explanation: 'improved' }], + tools: [{ toolName: 'search', recommendedToolDescription: 'new desc' }], configurationBundle: { bundleArn: BUNDLE_ARN, versionId: NEW_VERSION_ID }, }, }; diff --git a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts index 722be6535..f6a60b6e8 100644 --- a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts +++ b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts @@ -27,7 +27,6 @@ function makeResult(overrides: Partial = {}): Ru result: { systemPromptRecommendationResult: { recommendedSystemPrompt: 'You are an expert booking assistant.', - explanation: 'Made prompt more specific.', }, }, ...overrides, @@ -72,7 +71,9 @@ describe('recommendation-storage', () => { expect(loaded.type).toBe('SYSTEM_PROMPT_RECOMMENDATION'); expect(loaded.agent).toBe('booking-agent'); expect(loaded.evaluators).toEqual(['Builtin.Helpfulness']); - expect(loaded.result?.systemPromptRecommendationResult?.explanation).toBe('Made prompt more specific.'); + expect(loaded.result?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe( + 'You are an expert booking assistant.' + ); }); }); diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index 2185e4d93..364dfa211 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -428,11 +428,6 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results {sysResult && ( - {sysResult.explanation?.trim() && ( - - What changed: {sysResult.explanation} - - )} {sysResult.recommendedSystemPrompt && ( @@ -454,7 +449,6 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results {toolResult.tools.map(tool => ( {tool.toolName} - {tool.explanation?.trim() && Explanation: {tool.explanation}} {tool.recommendedToolDescription} ))} diff --git a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx index f2e003a88..b20e9bc57 100644 --- a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx @@ -140,11 +140,6 @@ function RecommendationDetailView({ record, onBack }: { record: RecommendationRu {sysResult && ( - {sysResult.explanation && ( - - What changed: {sysResult.explanation} - - )} {sysResult.recommendedSystemPrompt && ( @@ -166,7 +161,6 @@ function RecommendationDetailView({ record, onBack }: { record: RecommendationRu {toolResult.tools.map(tool => ( {tool.toolName} - {tool.explanation && Explanation: {tool.explanation}} {tool.recommendedToolDescription} ))} diff --git a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx index ef5d56483..382bb0c55 100644 --- a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx +++ b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx @@ -103,7 +103,7 @@ function BatchEvalListView({ } return ( - + {selected ? '>' : ' '} {date.padEnd(16)} {rec.status.padEnd(12)} @@ -155,7 +155,7 @@ function BatchEvalDetailView({ record, onBack }: { record: BatchEvalRunRecord; o - ID: {record.batchEvaluateId} + ID: {record.batchEvaluationId} Name: {record.name} @@ -176,11 +176,11 @@ function BatchEvalDetailView({ record, onBack }: { record: BatchEvalRunRecord; o )} - {evalRes?.totalSessions != null && ( + {evalRes?.totalNumberOfSessions != null && ( - Sessions: {evalRes.totalSessions} total - {evalRes.sessionsCompleted != null && , {evalRes.sessionsCompleted} completed} - {evalRes.sessionsFailed ? , {evalRes.sessionsFailed} failed : null} + Sessions: {evalRes.totalNumberOfSessions} total + {evalRes.numberOfSessionsCompleted != null && , {evalRes.numberOfSessionsCompleted} completed} + {evalRes.numberOfSessionsFailed ? , {evalRes.numberOfSessionsFailed} failed : null} )} diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 8ece98ec5..7a2726690 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -75,7 +75,7 @@ type FlowState = config: BatchEvalConfig; steps: Step[]; elapsed: number; - batchEvaluateId?: string; + batchEvaluationId?: string; region?: string; } | { name: 'results'; result: RunBatchEvaluationCommandResult; savedFilePath?: string } @@ -96,10 +96,10 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { // Handle Esc to stop a running batch evaluation useInput((_input, key) => { - if (flow.name !== 'running' || !flow.batchEvaluateId || !flow.region || stoppingRef.current) return; + if (flow.name !== 'running' || !flow.batchEvaluationId || !flow.region || stoppingRef.current) return; if (key.escape) { stoppingRef.current = true; - void stopBatchEvaluation({ region: flow.region, batchEvaluateId: flow.batchEvaluateId }).catch(() => { + void stopBatchEvaluation({ region: flow.region, batchEvaluationId: flow.batchEvaluationId }).catch(() => { // Best-effort — the poll loop will pick up the final status }); setFlow(prev => { @@ -238,7 +238,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { onStarted: info => { setFlow(prev => { if (prev.name !== 'running') return prev; - return { ...prev, batchEvaluateId: info.batchEvaluateId, region: info.region }; + return { ...prev, batchEvaluationId: info.batchEvaluationId, region: info.region }; }); }, }); @@ -346,7 +346,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { This may take a few minutes... - {flow.batchEvaluateId && Press Esc to stop the evaluation} + {flow.batchEvaluationId && Press Esc to stop the evaluation} @@ -867,7 +867,7 @@ function ResultsView({ result, savedFilePath, onRunAnother, onExit }: ResultsVie ✓ Batch evaluation complete - ID: {result.batchEvaluateId} + ID: {result.batchEvaluationId} {' '} Status: {result.status} @@ -877,11 +877,15 @@ function ResultsView({ result, savedFilePath, onRunAnother, onExit }: ResultsVie )} - {evalRes?.totalSessions != null && ( + {evalRes?.totalNumberOfSessions != null && ( - Sessions: {evalRes.totalSessions} total - {evalRes.sessionsCompleted != null && , {evalRes.sessionsCompleted} completed} - {evalRes.sessionsFailed ? , {evalRes.sessionsFailed} failed : null} + Sessions: {evalRes.totalNumberOfSessions} total + {evalRes.numberOfSessionsCompleted != null && ( + , {evalRes.numberOfSessionsCompleted} completed + )} + {evalRes.numberOfSessionsFailed ? ( + , {evalRes.numberOfSessionsFailed} failed + ) : null} )} From 2ef8132251b875c87df664d39cabfdcdce81abdb Mon Sep 17 00:00:00 2001 From: "T.J Ariyawansa" Date: Mon, 27 Apr 2026 19:30:51 -0400 Subject: [PATCH 50/64] Merge remote-tracking branch 'origin/main' into feat/evo-implementation --- .github/scripts/prompts/review.md | 13 + .github/scripts/prompts/system.md | 21 + .github/scripts/python/harness_review.py | 217 + .github/workflows/release.yml | 26 - .gitignore | 6 + .prettierignore | 1 + AGENTS.md | 38 + CHANGELOG.md | 97 + 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 | 45 +- docs/configuration.md | 6 +- docs/container-builds.md | 2 +- e2e-tests/byo-custom-jwt.test.ts | 22 +- e2e-tests/fixtures/import/app/main.py | 44 + .../fixtures/import/app/model/__init__.py | 0 e2e-tests/fixtures/import/app/model/load.py | 6 + e2e-tests/fixtures/import/app/pyproject.toml | 17 + .../fixtures/import/cleanup_resources.py | 79 + e2e-tests/fixtures/import/common.py | 258 + e2e-tests/fixtures/import/setup_evaluator.py | 85 + .../fixtures/import/setup_memory_full.py | 80 + .../fixtures/import/setup_runtime_basic.py | 69 + e2e-tests/import-resources.test.ts | 217 + eslint.config.mjs | 85 +- package-lock.json | 19061 +++++++++------- package.json | 20 +- schemas/agentcore.schema.v1.json | 37 +- scripts/bump-version.ts | 87 +- scripts/copy-assets.mjs | 14 + src/assets/AGENTS.md | 43 +- src/assets/README.md | 87 +- .../assets.snapshot.test.ts.snap | 1941 +- .../dockerfile-render.test.ts.snap | 77 + .../__tests__/dockerfile-render.test.ts | 24 + src/assets/agents/AGENTS.md | 139 +- src/assets/cdk/cdk.json | 2 +- src/assets/cdk/package.json | 2 +- src/assets/container/python/Dockerfile | 4 + .../python-lambda/execution-role-policy.json | 2 +- src/assets/python/a2a/googleadk/base/main.py | 68 +- .../a2a/langchain_langgraph/base/main.py | 68 +- .../langchain_langgraph/base/pyproject.toml | 1 + src/assets/python/a2a/strands/base/main.py | 67 +- .../strands/capabilities/memory/session.py | 7 +- .../python/agui/googleadk/base/README.md | 30 + .../agui/googleadk/base/gitignore.template | 41 + src/assets/python/agui/googleadk/base/main.py | 31 + .../agui/googleadk/base/model/__init__.py | 1 + .../python/agui/googleadk/base/model/load.py | 41 + .../python/agui/googleadk/base/pyproject.toml | 24 + .../agui/langchain_langgraph/base/README.md | 22 + .../base/gitignore.template | 41 + .../agui/langchain_langgraph/base/main.py | 74 + .../base/model/__init__.py | 1 + .../langchain_langgraph/base/model/load.py | 123 + .../langchain_langgraph/base/pyproject.toml | 30 + src/assets/python/agui/strands/base/README.md | 22 + .../agui/strands/base/gitignore.template | 41 + src/assets/python/agui/strands/base/main.py | 43 + .../agui/strands/base/model/__init__.py | 1 + .../python/agui/strands/base/model/load.py | 123 + .../python/agui/strands/base/pyproject.toml | 27 + .../strands/capabilities/memory/__init__.py | 1 + .../strands/capabilities/memory/session.py | 43 + src/assets/python/http/autogen/base/main.py | 62 +- src/assets/python/http/googleadk/base/main.py | 64 +- .../http/langchain_langgraph/base/main.py | 65 +- .../langchain_langgraph/base/pyproject.toml | 1 + .../python/http/openaiagents/base/main.py | 74 +- src/assets/python/http/strands/base/main.py | 70 +- .../strands/capabilities/memory/session.py | 7 +- src/cli/aws/__tests__/agui-types.test.ts | 262 + src/cli/aws/__tests__/partition.test.ts | 76 + src/cli/aws/__tests__/target-region.test.ts | 99 + src/cli/aws/agentcore.ts | 79 +- src/cli/aws/agui-parser.ts | 123 + src/cli/aws/agui-types.ts | 406 + src/cli/aws/bedrock-import.ts | 2 +- src/cli/aws/cloudwatch.ts | 3 +- src/cli/aws/index.ts | 43 + src/cli/aws/partition.ts | 28 + src/cli/aws/target-region.ts | 55 + src/cli/aws/transaction-search.ts | 7 +- src/cli/cli.ts | 19 + src/cli/cloudformation/outputs.ts | 42 + .../commands/add/__tests__/validate.test.ts | 51 + src/cli/commands/add/command.tsx | 3 +- src/cli/commands/add/types.ts | 2 + src/cli/commands/add/validate.ts | 29 +- .../commands/create/__tests__/create.test.ts | 65 + .../create/__tests__/validate.test.ts | 82 + src/cli/commands/create/action.ts | 29 +- src/cli/commands/create/command.tsx | 27 +- src/cli/commands/create/types.ts | 2 + src/cli/commands/create/validate.ts | 31 +- src/cli/commands/deploy/actions.ts | 20 + src/cli/commands/deploy/command.tsx | 4 +- src/cli/commands/dev/browser-mode.ts | 207 + src/cli/commands/dev/command.tsx | 109 +- .../import/__tests__/import-memory.test.ts | 10 +- .../import/__tests__/merge-logic.test.ts | 4 +- src/cli/commands/import/actions.ts | 9 +- src/cli/commands/import/command.ts | 2 +- src/cli/commands/import/constants.ts | 1 + src/cli/commands/import/import-memory.ts | 2 +- src/cli/commands/import/import-online-eval.ts | 3 +- src/cli/commands/import/import-utils.ts | 10 +- src/cli/commands/import/types.ts | 2 +- src/cli/commands/import/yaml-parser.ts | 5 +- .../invoke/__tests__/resolve-prompt.test.ts | 88 + src/cli/commands/invoke/action.ts | 75 +- src/cli/commands/invoke/command.tsx | 52 +- 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 | 41 +- src/cli/commands/status/command.tsx | 48 +- src/cli/commands/status/constants.ts | 3 +- src/cli/commands/telemetry/actions.ts | 2 +- .../__tests__/detect.test.ts | 100 +- src/cli/external-requirements/detect.ts | 39 +- src/cli/external-requirements/index.ts | 1 - .../invoke-logger-session-id.test.ts | 96 + src/cli/logging/remove-logger.ts | 1 + .../generate/__tests__/schema-mapper.test.ts | 2 +- .../agent/generate/schema-mapper.ts | 6 + .../agent/import/__tests__/translator.test.ts | 1 + .../agent/import/base-translator.ts | 5 +- src/cli/operations/agent/import/constants.ts | 1 + src/cli/operations/agent/import/index.ts | 3 + .../agent/import/pyproject-generator.ts | 1 + src/cli/operations/deploy/teardown.ts | 19 +- .../dev/__tests__/codezip-dev-server.test.ts | 6 +- .../__tests__/container-dev-server.test.ts | 37 +- .../dev/__tests__/utils-open-browser.test.ts | 57 + src/cli/operations/dev/codezip-dev-server.ts | 59 +- .../operations/dev/container-dev-server.ts | 30 +- src/cli/operations/dev/index.ts | 4 + src/cli/operations/dev/invoke-a2a.ts | 6 +- src/cli/operations/dev/invoke-agui.ts | 153 + src/cli/operations/dev/invoke-types.ts | 2 + src/cli/operations/dev/invoke.ts | 6 +- src/cli/operations/dev/load-dev-env.ts | 26 + src/cli/operations/dev/otel/collector.ts | 309 + src/cli/operations/dev/otel/index.ts | 2 + src/cli/operations/dev/otel/transforms.ts | 286 + src/cli/operations/dev/otel/types.ts | 56 + src/cli/operations/dev/utils.ts | 10 + src/cli/operations/dev/web-ui/README.md | 236 + .../dev/web-ui/__tests__/mcp-proxy.test.ts | 175 + .../__tests__/resolve-ui-dist-dir.test.ts | 62 + src/cli/operations/dev/web-ui/api-types.ts | 380 + src/cli/operations/dev/web-ui/constants.ts | 18 + .../dev/web-ui/handlers/a2a-proxy.ts | 48 + .../operations/dev/web-ui/handlers/index.ts | 9 + .../dev/web-ui/handlers/invocations.ts | 332 + .../dev/web-ui/handlers/mcp-proxy.ts | 82 + .../operations/dev/web-ui/handlers/memory.ts | 125 + .../dev/web-ui/handlers/resources.ts | 293 + .../dev/web-ui/handlers/route-context.ts | 41 + .../operations/dev/web-ui/handlers/start.ts | 186 + .../operations/dev/web-ui/handlers/status.ts | 25 + .../operations/dev/web-ui/handlers/traces.ts | 119 + src/cli/operations/dev/web-ui/index.ts | 37 + src/cli/operations/dev/web-ui/run-web-ui.ts | 60 + src/cli/operations/dev/web-ui/web-server.ts | 374 + .../eval/__tests__/logs-eval.test.ts | 10 +- src/cli/operations/memory/index.ts | 11 + .../operations/memory/list-memory-records.ts | 70 + .../memory/retrieve-memory-records.ts | 69 + .../get-agent-scoped-credentials.test.ts | 4 +- src/cli/operations/traces/trace-url.ts | 5 +- src/cli/primitives/AgentPrimitive.tsx | 47 +- src/cli/primitives/BasePrimitive.ts | 2 + src/cli/primitives/CredentialPrimitive.tsx | 3 + src/cli/primitives/EvaluatorPrimitive.ts | 3 + src/cli/primitives/GatewayPrimitive.ts | 4 +- src/cli/primitives/GatewayTargetPrimitive.ts | 2 + src/cli/primitives/MemoryPrimitive.tsx | 3 + .../primitives/OnlineEvalConfigPrimitive.ts | 15 +- src/cli/primitives/PolicyEnginePrimitive.ts | 3 + src/cli/primitives/PolicyPrimitive.ts | 4 + .../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/templates/BaseRenderer.ts | 7 +- src/cli/templates/types.ts | 6 +- src/cli/tui/App.tsx | 16 +- src/cli/tui/components/ResourceGraph.tsx | 50 +- .../__tests__/ResourceGraph.test.tsx | 31 - src/cli/tui/exit-action.ts | 21 + src/cli/tui/guards/index.ts | 1 + src/cli/tui/guards/tty.ts | 13 + src/cli/tui/hooks/useCdkPreflight.ts | 44 +- src/cli/tui/hooks/useDevServer.ts | 51 +- src/cli/tui/hooks/useRemove.ts | 24 + src/cli/tui/screens/add/AddFlow.tsx | 46 +- src/cli/tui/screens/add/AddScreen.tsx | 3 +- src/cli/tui/screens/agent/AddAgentScreen.tsx | 36 +- src/cli/tui/screens/agent/types.ts | 8 +- src/cli/tui/screens/agent/useAddAgent.ts | 5 + src/cli/tui/screens/create/CreateScreen.tsx | 145 +- src/cli/tui/screens/create/useCreateFlow.ts | 4 + src/cli/tui/screens/deploy/DeployScreen.tsx | 247 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 15 +- src/cli/tui/screens/dev/DevScreen.tsx | 2 +- .../tui/screens/generate/GenerateWizardUI.tsx | 35 +- .../__tests__/useGenerateWizard.test.tsx | 127 + src/cli/tui/screens/generate/defaults.ts | 7 +- src/cli/tui/screens/generate/types.ts | 8 +- .../tui/screens/generate/useGenerateWizard.ts | 47 +- .../tui/screens/home/CommandListScreen.tsx | 9 +- src/cli/tui/screens/home/HelpScreen.tsx | 23 +- src/cli/tui/screens/import/ArnInputScreen.tsx | 6 +- .../tui/screens/import/ImportSelectScreen.tsx | 15 +- src/cli/tui/screens/invoke/InvokeScreen.tsx | 37 +- src/cli/tui/screens/invoke/useInvokeFlow.ts | 139 +- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 6 +- src/cli/tui/screens/mcp/types.ts | 6 +- .../screens/online-eval/AddOnlineEvalFlow.tsx | 17 +- .../screens/policy/AddPolicyEngineScreen.tsx | 4 +- src/cli/tui/screens/policy/AddPolicyFlow.tsx | 62 +- 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 | 4 + 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/cli/tui/utils/commands.ts | 2 +- src/lib/constants.ts | 10 - src/lib/errors/config.ts | 7 +- src/lib/index.ts | 1 - src/lib/packaging/__tests__/helpers.test.ts | 128 + src/lib/packaging/__tests__/python.test.ts | 4 + src/lib/packaging/__tests__/uv.test.ts | 14 + src/lib/packaging/helpers.ts | 42 +- src/lib/packaging/index.ts | 3 +- src/lib/packaging/python.ts | 3 +- src/lib/packaging/types/fflate.d.ts | 13 - src/lib/packaging/uv.ts | 3 +- src/schema/__tests__/constants.test.ts | 8 +- src/schema/constants.ts | 14 +- src/schema/llm-compacted/README.md | 9 +- src/schema/llm-compacted/agentcore.ts | 7 +- src/schema/llm-compacted/aws-targets.ts | 10 +- src/schema/llm-compacted/mcp.ts | 2 +- .../schemas/__tests__/agent-env.test.ts | 132 +- .../__tests__/agentcore-project.test.ts | 8 +- .../schemas/__tests__/aws-targets.test.ts | 11 +- .../schemas/__tests__/deployed-state.test.ts | 32 + src/schema/schemas/agent-env.ts | 48 +- src/schema/schemas/agentcore-project.ts | 11 +- src/schema/schemas/aws-targets.ts | 8 + src/schema/schemas/deployed-state.ts | 14 + src/schema/schemas/primitives/evaluator.ts | 1 - src/test-utils/cli-runner.ts | 1 + .../__tests__/proof-of-concept.test.ts | 12 +- src/tui-harness/helpers.ts | 3 +- tsconfig.json | 1 + vitest.config.ts | 9 +- 295 files changed, 26028 insertions(+), 9637 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 e2e-tests/fixtures/import/app/main.py create mode 100644 e2e-tests/fixtures/import/app/model/__init__.py create mode 100644 e2e-tests/fixtures/import/app/model/load.py create mode 100644 e2e-tests/fixtures/import/app/pyproject.toml create mode 100644 e2e-tests/fixtures/import/cleanup_resources.py create mode 100644 e2e-tests/fixtures/import/common.py create mode 100644 e2e-tests/fixtures/import/setup_evaluator.py create mode 100644 e2e-tests/fixtures/import/setup_memory_full.py create mode 100644 e2e-tests/fixtures/import/setup_runtime_basic.py create mode 100644 e2e-tests/import-resources.test.ts create mode 100644 src/assets/__tests__/__snapshots__/dockerfile-render.test.ts.snap create mode 100644 src/assets/__tests__/dockerfile-render.test.ts create mode 100644 src/assets/python/agui/googleadk/base/README.md create mode 100644 src/assets/python/agui/googleadk/base/gitignore.template create mode 100644 src/assets/python/agui/googleadk/base/main.py create mode 100644 src/assets/python/agui/googleadk/base/model/__init__.py create mode 100644 src/assets/python/agui/googleadk/base/model/load.py create mode 100644 src/assets/python/agui/googleadk/base/pyproject.toml create mode 100644 src/assets/python/agui/langchain_langgraph/base/README.md create mode 100644 src/assets/python/agui/langchain_langgraph/base/gitignore.template create mode 100644 src/assets/python/agui/langchain_langgraph/base/main.py create mode 100644 src/assets/python/agui/langchain_langgraph/base/model/__init__.py create mode 100644 src/assets/python/agui/langchain_langgraph/base/model/load.py create mode 100644 src/assets/python/agui/langchain_langgraph/base/pyproject.toml create mode 100644 src/assets/python/agui/strands/base/README.md create mode 100644 src/assets/python/agui/strands/base/gitignore.template create mode 100644 src/assets/python/agui/strands/base/main.py create mode 100644 src/assets/python/agui/strands/base/model/__init__.py create mode 100644 src/assets/python/agui/strands/base/model/load.py create mode 100644 src/assets/python/agui/strands/base/pyproject.toml create mode 100644 src/assets/python/agui/strands/capabilities/memory/__init__.py create mode 100644 src/assets/python/agui/strands/capabilities/memory/session.py create mode 100644 src/cli/aws/__tests__/agui-types.test.ts create mode 100644 src/cli/aws/__tests__/partition.test.ts create mode 100644 src/cli/aws/__tests__/target-region.test.ts create mode 100644 src/cli/aws/agui-parser.ts create mode 100644 src/cli/aws/agui-types.ts create mode 100644 src/cli/aws/partition.ts create mode 100644 src/cli/aws/target-region.ts create mode 100644 src/cli/commands/dev/browser-mode.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/logging/__tests__/invoke-logger-session-id.test.ts create mode 100644 src/cli/operations/dev/__tests__/utils-open-browser.test.ts create mode 100644 src/cli/operations/dev/invoke-agui.ts create mode 100644 src/cli/operations/dev/load-dev-env.ts create mode 100644 src/cli/operations/dev/otel/collector.ts create mode 100644 src/cli/operations/dev/otel/index.ts create mode 100644 src/cli/operations/dev/otel/transforms.ts create mode 100644 src/cli/operations/dev/otel/types.ts create mode 100644 src/cli/operations/dev/web-ui/README.md create mode 100644 src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts create mode 100644 src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts create mode 100644 src/cli/operations/dev/web-ui/api-types.ts create mode 100644 src/cli/operations/dev/web-ui/constants.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/a2a-proxy.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/index.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/invocations.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/memory.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/resources.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/route-context.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/start.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/status.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/traces.ts create mode 100644 src/cli/operations/dev/web-ui/index.ts create mode 100644 src/cli/operations/dev/web-ui/run-web-ui.ts create mode 100644 src/cli/operations/dev/web-ui/web-server.ts create mode 100644 src/cli/operations/memory/index.ts create mode 100644 src/cli/operations/memory/list-memory-records.ts create mode 100644 src/cli/operations/memory/retrieve-memory-records.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/exit-action.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 delete mode 100644 src/lib/packaging/types/fflate.d.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/.github/workflows/release.yml b/.github/workflows/release.yml index 712e7e4a8..21afc4e1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,32 +62,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Sync @aws/agentcore-cdk to latest npm version - run: | - echo "Fetching latest @aws/agentcore-cdk version from npm..." - LATEST_CDK=$(npm view @aws/agentcore-cdk version 2>/dev/null || echo "") - - if [ -z "$LATEST_CDK" ]; then - echo "⚠️ Could not fetch @aws/agentcore-cdk version from npm. Skipping sync." - else - TEMPLATE_PKG="src/assets/cdk/package.json" - CURRENT_CDK=$(node -p "require('./$TEMPLATE_PKG').dependencies['@aws/agentcore-cdk']") - echo "Current pinned version: $CURRENT_CDK" - echo "Latest npm version: $LATEST_CDK" - - if [ "$CURRENT_CDK" != "$LATEST_CDK" ]; then - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('$TEMPLATE_PKG', 'utf8')); - pkg.dependencies['@aws/agentcore-cdk'] = '$LATEST_CDK'; - fs.writeFileSync('$TEMPLATE_PKG', JSON.stringify(pkg, null, 2) + '\n'); - " - echo "✅ Updated @aws/agentcore-cdk: $CURRENT_CDK -> $LATEST_CDK" - else - echo "✅ @aws/agentcore-cdk already at latest ($LATEST_CDK)" - fi - fi - - name: Get current version id: current run: | 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/AGENTS.md b/AGENTS.md index 892ee0e02..104b902bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,44 @@ 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. +## Multi-Partition Support (GovCloud, China) + +The CLI supports multiple AWS partitions (commercial, GovCloud, China) through a central utility at +`src/cli/aws/partition.ts`. This module maps region prefixes to partition-specific values. + +### Rules + +- **Never hardcode `arn:aws:`** in ARN construction. Use `arnPrefix(region)` from `src/cli/aws/partition.ts`. +- **Never hardcode `amazonaws.com`** in endpoint URLs. Use `serviceEndpoint(service, region)` or `dnsSuffix(region)`. +- **Never hardcode `console.aws.amazon.com`** in console URLs. Use `consoleDomain(region)`. +- **ARN regex patterns** must use `arn:[^:]+:` (not `arn:aws:`) to match any partition. +- **Static JSON assets** (e.g., IAM policies in `src/assets/`) cannot use TypeScript utilities — use `arn:*:` as the + partition wildcard since IAM evaluates it across all partitions. + +### Adding a New Region + +Update these files in the CLI repo: + +1. `src/schema/schemas/aws-targets.ts` — add to `AgentCoreRegionSchema` enum +2. `src/schema/llm-compacted/aws-targets.ts` — add to `AgentCoreRegion` type union +3. `src/schema/schemas/__tests__/aws-targets.test.ts` — add to `validRegions` array +4. `src/cli/operations/agent/import/constants.ts` — add to `BEDROCK_REGIONS` (if applicable to Bedrock Agent import) + +Update these files in the CDK repo (`@aws/agentcore-cdk`): + +5. `src/schema/schemas/aws-targets.ts` — add to `AgentCoreRegionSchema` enum +6. `src/schema/llm-compacted/aws-targets.ts` — add to `AgentCoreRegion` type union + +Then run `npm run test:update-snapshots` in the CLI repo if any asset files changed. + +### Adding a New Partition + +1. Add a new entry to `PARTITION_CONFIGS` in `src/cli/aws/partition.ts` with the region prefix, partition name, DNS + suffix, and console domain. +2. Add tests for the new partition in `src/cli/aws/__tests__/partition.test.ts`. +3. Update `src/assets/cdk/cdk.json` — add the partition to `@aws-cdk/core:target-partitions`. +4. Run `npm run test:update-snapshots` to update asset snapshots. + ## TUI Harness See `docs/tui-harness.md` for the full TUI harness usage guide (MCP tools, screen markers, examples, and error diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6afb314..ec13d3ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,103 @@ 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 +- feat: upgrade agent inspector to 0.2.1 (#937) (b49a06f) +- feat: remove deployed/local from status legend (#936) (c0d5b7b) +- feat: add GovCloud multi-partition support (#908) (098b104) +- feat: support preview releases from feature branches (#905) (1a93f92) +- feat: add AG-UI (AGUI) as fourth first-class protocol mode (#858) (52144dc) +- feat: add session filesystem storage support (#893) (b97e337) + +### Fixed +- fix: agentcore add component opens component wizard directly (#896) (74a35cb) +- fix: propagate sessionId as A2A contextId in Inspector proxy (#892) (08d452e) + +### Documentation +- docs: update vended AGENTS.md, README.md, and llm-context references (#898) (84a6dde) + +### Other Changes +- fix(deploy): honor aws-targets.json region for all SDK and CDK calls (#925) (1903f7d) +- fix(invoke): show full session ID and print resume command on exit (#904) (ce683c0) +- chore: remove preview bump type from release workflow (#847) (13f16d3) +- chore: remove single-commit-must-match-PR-title validation (#897) (4d7da2f) +- fix(invoke): pass session ID to local invoke log files (#894) (e966cb6) + +## [0.9.1] - 2026-04-17 + +## [0.9.0] - 2026-04-17 + +### Fixed +- fix: revert version to 0.8.2 (#885) (321ea06) +- fix: agent-inspector frontend assets missing from build (#883) (08f826c) +- fix: use caret range for @aws/agentcore-cdk in CDK template (#882) (e01f6f9) +- fix: defer policy engine write and harden policy flow UX (#856) (c576d02) + +### Added +- feat: add agent inspector web UI for `agentcore dev` (#871) (6cc575c) + +### Documentation +- docs: document executionRoleArn in runtime spec (#872) (abfd33b) + +## [0.8.2] - 2026-04-16 + +### Added +- feat: upgrade default Python runtime to PYTHON_3_14 (#837) (b139c05) + +### Other Changes +- revert: roll back version bump to 0.8.1 (#877) (ef14108) +- test: update asset snapshot for @aws/agentcore-cdk 0.1.0-alpha.19 (#875) (f781c60) +- chore: bump version to 0.8.2 (#874) (865b5d5) + +## [0.8.1] - 2026-04-14 + +### Added +- feat: add auto-instrumentation to langchain agent template (#835) (31fb7d1) +- feat: add e2e tests for import command (#828) (bb9de25) +- feat: add --request-header-allowlist CLI flag for agentcore add agent (#825) (#830) (b433faf) + +### Fixed +- fix: pin @aws/agentcore-cdk to exact version in CDK template (#852) (aff1097) +- fix: only exclude root-level agentcore/ directory from packaging artifacts (#844) (c3921ec) +- fix: add AWS_IAM as a valid authorizer type for gateway commands (#820) (f2964e3) +- fix: add missing langchain instrumentor dependency to import flow (#836) (921a05f) +- fix: unhide import command from TUI main menu (#834) (ee6b630) +- fix: add missing AgentCore regions to match AWS documentation (#833) (3b60dbe) +- fix: remove docker info check from container runtime detection (#829) (6729eb2) +- fix: update E2E test regex to match new CUSTOM_JWT client-side error (#832) (4f178a5) +- fix: fail fast when CUSTOM_JWT agent has no bearer token available (#817) (96de3d2) +- fix: respect aws-targets.json region instead of overriding with AWS_REGION env var (#818) (bdcc954) +- fix: use caret range for aws-cdk-lib in project template (#805) (6e19463) + +### Other Changes +- fix(ci): bump @aws/agentcore-cdk to 0.1.0-alpha.18 and remove snapshot step from release (#850) (e885843) +- fix(ci): move snapshot update after build step in release workflow (#849) (37665a3) +- fix(ci): update snapshots after CDK version sync in release workflow (#848) (6f87f04) +- fix(e2e): use uv run for import test Python scripts (#845) (5962711) +- fix(ci): unpin boto3 in e2e workflow (#841) (e64e8e2) +- chore: pin @aws/agentcore-cdk version and auto-sync on release (#811) (1e5c631) +- chore: bump aws-cdk-lib peer dep to ^2.248.0 (#812) (16b3c8c) + ## [0.8.0] - 2026-04-09 ### 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 281be5c79..f6f15b9ae 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -276,7 +276,7 @@ agentcore add gateway \ | `--name ` | Gateway name | | `--description ` | Gateway description | | `--runtimes ` | Comma-separated runtime names to expose through this gateway | -| `--authorizer-type ` | `NONE` (default) or `CUSTOM_JWT` | +| `--authorizer-type ` | `NONE` (default), `AWS_IAM`, or `CUSTOM_JWT` | | `--discovery-url ` | OIDC discovery URL (required for CUSTOM_JWT) | | `--allowed-audience ` | Comma-separated allowed audiences (required for CUSTOM_JWT) | | `--allowed-clients ` | Comma-separated allowed client IDs (required for CUSTOM_JWT) | @@ -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/docs/configuration.md b/docs/configuration.md index 827d884d2..6e42c101a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,7 +31,7 @@ Main project configuration using a **flat resource model**. Agents, memories, an "build": "CodeZip", "entrypoint": "main.py", "codeLocation": "app/MyAgent/", - "runtimeVersion": "PYTHON_3_13", + "runtimeVersion": "PYTHON_3_14", "networkMode": "PUBLIC", "protocol": "HTTP" } @@ -166,7 +166,7 @@ on the next deployment. "build": "CodeZip", "entrypoint": "main.py", "codeLocation": "app/MyAgent/", - "runtimeVersion": "PYTHON_3_13", + "runtimeVersion": "PYTHON_3_14", "networkMode": "PUBLIC", "envVars": [{ "name": "MY_VAR", "value": "my-value" }], "instrumentation": { @@ -191,6 +191,7 @@ on the next deployment. | `authorizerConfiguration` | No | JWT authorizer settings (for `CUSTOM_JWT`) | | `requestHeaderAllowlist` | No | Headers to forward to the agent | | `lifecycleConfiguration` | No | Runtime session lifecycle settings (idle timeout, max lifetime) | +| `executionRoleArn` | No | ARN of an existing IAM execution role (skips CDK-managed role) | | `tags` | No | Agent-level tags | ### Runtime Versions @@ -201,6 +202,7 @@ on the next deployment. - `PYTHON_3_11` - `PYTHON_3_12` - `PYTHON_3_13` +- `PYTHON_3_14` **Node.js:** diff --git a/docs/container-builds.md b/docs/container-builds.md index a75f5e7a9..61d65bcde 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -58,7 +58,7 @@ In `agentcore.json`, set `"build": "Container"`: "build": "Container", "entrypoint": "main.py", "codeLocation": "app/MyAgent/", - "runtimeVersion": "PYTHON_3_13" + "runtimeVersion": "PYTHON_3_14" } ``` diff --git a/e2e-tests/byo-custom-jwt.test.ts b/e2e-tests/byo-custom-jwt.test.ts index 27eebf211..b7391a522 100644 --- a/e2e-tests/byo-custom-jwt.test.ts +++ b/e2e-tests/byo-custom-jwt.test.ts @@ -272,9 +272,11 @@ describe.sequential('e2e: BYO agent with CUSTOM_JWT auth', () => { projectPath ); - // Expect failure due to auth method mismatch + // Expect failure due to auth method mismatch (client-side fast-fail or server-side rejection) const output = stripAnsi(result.stdout + result.stderr); - expect(output).toMatch(/[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i); + expect(output).toMatch( + /configured for CUSTOM_JWT but no bearer token|[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i + ); }, 180000 ); @@ -294,7 +296,9 @@ describe.sequential('e2e: BYO agent with CUSTOM_JWT auth', () => { const output = stripAnsi(result.stdout + result.stderr); // The invoke may fail for other reasons (agent logic), but it should NOT fail with auth mismatch - expect(output).not.toMatch(/[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i); + expect(output).not.toMatch( + /configured for CUSTOM_JWT but no bearer token|[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i + ); }, 180000 ); @@ -307,7 +311,9 @@ describe.sequential('e2e: BYO agent with CUSTOM_JWT auth', () => { const result = await runLocalCLI(['invoke', '--runtime', mcpAgentName, 'list-tools', '--json'], projectPath); const output = stripAnsi(result.stdout + result.stderr); - expect(output).toMatch(/[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i); + expect(output).toMatch( + /configured for CUSTOM_JWT but no bearer token|[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i + ); }, 180000 ); @@ -325,7 +331,9 @@ describe.sequential('e2e: BYO agent with CUSTOM_JWT auth', () => { ); const output = stripAnsi(result.stdout + result.stderr); - expect(output).not.toMatch(/[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i); + expect(output).not.toMatch( + /configured for CUSTOM_JWT but no bearer token|[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i + ); }, 180000 ); @@ -355,7 +363,9 @@ describe.sequential('e2e: BYO agent with CUSTOM_JWT auth', () => { ); const output = stripAnsi(result.stdout + result.stderr); - expect(output).not.toMatch(/[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i); + expect(output).not.toMatch( + /configured for CUSTOM_JWT but no bearer token|[Aa]uthoriz(ation|er).*mismatch|different.*authorization/i + ); }, 180000 ); diff --git a/e2e-tests/fixtures/import/app/main.py b/e2e-tests/fixtures/import/app/main.py new file mode 100644 index 000000000..2400ee741 --- /dev/null +++ b/e2e-tests/fixtures/import/app/main.py @@ -0,0 +1,44 @@ +from strands import Agent, tool +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from model.load import load_model + +app = BedrockAgentCoreApp() +log = app.logger + +tools = [] + + +@tool +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers""" + return a + b + + +tools.append(add_numbers) + +_agent = None + + +def get_or_create_agent(): + global _agent + if _agent is None: + _agent = Agent( + model=load_model(), + system_prompt="You are a helpful assistant. Use tools when appropriate.", + tools=tools, + ) + return _agent + + +@app.entrypoint +async def invoke(payload, context): + log.info("Invoking Agent.....") + agent = get_or_create_agent() + stream = agent.stream_async(payload.get("prompt")) + async for event in stream: + if "data" in event and isinstance(event["data"], str): + yield event["data"] + + +if __name__ == "__main__": + app.run() diff --git a/e2e-tests/fixtures/import/app/model/__init__.py b/e2e-tests/fixtures/import/app/model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/e2e-tests/fixtures/import/app/model/load.py b/e2e-tests/fixtures/import/app/model/load.py new file mode 100644 index 000000000..d64457687 --- /dev/null +++ b/e2e-tests/fixtures/import/app/model/load.py @@ -0,0 +1,6 @@ +from strands.models.bedrock import BedrockModel + + +def load_model() -> BedrockModel: + """Get Bedrock model client using IAM credentials.""" + return BedrockModel(model_id="us.anthropic.claude-sonnet-4-5-20250514-v1:0") diff --git a/e2e-tests/fixtures/import/app/pyproject.toml b/e2e-tests/fixtures/import/app/pyproject.toml new file mode 100644 index 000000000..6c018f34e --- /dev/null +++ b/e2e-tests/fixtures/import/app/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "bugbash-agent" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "aws-opentelemetry-distro", + "bedrock-agentcore >= 1.0.3", + "botocore[crt] >= 1.35.0", + "strands-agents >= 1.13.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/e2e-tests/fixtures/import/cleanup_resources.py b/e2e-tests/fixtures/import/cleanup_resources.py new file mode 100644 index 000000000..120ced18f --- /dev/null +++ b/e2e-tests/fixtures/import/cleanup_resources.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Delete all resources tracked in bugbash-resources.json. + +Called from afterAll in import e2e tests as a fallback cleanup +for resources that were not successfully imported into CloudFormation. + +Note: The IAM role (bugbash-agentcore-role) is intentionally left in place — +it is shared across test runs via ensure_role() in common.py. +""" +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from common import REGION, RESOURCES_FILE, get_control_client, get_account_id + +import boto3 + + +def cleanup_s3_code_objects(): + """Delete uploaded code.zip objects from the bugbash S3 bucket.""" + account_id = get_account_id() + bucket_name = f"bugbash-agentcore-code-{account_id}-{REGION}" + s3 = boto3.client("s3", region_name=REGION) + try: + resp = s3.list_objects_v2(Bucket=bucket_name) + objects = resp.get("Contents", []) + if not objects: + return + s3.delete_objects( + Bucket=bucket_name, + Delete={"Objects": [{"Key": o["Key"]} for o in objects]}, + ) + print(f"Deleted {len(objects)} object(s) from s3://{bucket_name}") + except Exception as e: + print(f"Could not clean up S3 objects: {e}") + + +def main(): + if not os.path.exists(RESOURCES_FILE): + print("No bugbash-resources.json found, nothing to clean up") + return + + with open(RESOURCES_FILE) as f: + resources = json.load(f) + + client = get_control_client() + + failed = [] + for key, val in resources.items(): + rid = val.get("id") + if not rid: + continue + try: + if "runtime" in key: + client.delete_agent_runtime(agentRuntimeId=rid) + elif "memory" in key: + client.delete_memory(memoryId=rid) + elif "evaluator" in key: + client.delete_evaluator(evaluatorId=rid) + print(f"Deleted {key}: {rid}") + except Exception as e: + print(f"Could not delete {key} ({rid}): {e}") + failed.append(key) + + if failed: + remaining = {k: v for k, v in resources.items() if k in failed} + with open(RESOURCES_FILE, "w") as f: + json.dump(remaining, f, indent=2) + print(f"WARNING: {len(failed)} resources could not be deleted, kept in {RESOURCES_FILE}") + else: + os.remove(RESOURCES_FILE) + print("Cleaned up bugbash-resources.json") + + cleanup_s3_code_objects() + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/fixtures/import/common.py b/e2e-tests/fixtures/import/common.py new file mode 100644 index 000000000..b49ffb277 --- /dev/null +++ b/e2e-tests/fixtures/import/common.py @@ -0,0 +1,258 @@ +"""Shared helpers for bugbash setup scripts.""" +import json +import os +import time +import zipfile +import tempfile + +import boto3 + +REGION = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1" +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") +INLINE_POLICY_NAME = "bugbash-agentcore-permissions" + + +def get_code_bucket(): + """Return the code bucket name, creating it if needed.""" + account_id = get_account_id() + bucket_name = f"bugbash-agentcore-code-{account_id}-{REGION}" + s3 = boto3.client("s3", region_name=REGION) + try: + s3.head_bucket(Bucket=bucket_name) + print(f"S3 bucket already exists: {bucket_name}") + except s3.exceptions.ClientError: + print(f"Creating S3 bucket: {bucket_name}") + create_args = {"Bucket": bucket_name} + if REGION != "us-east-1": + create_args["CreateBucketConfiguration"] = {"LocationConstraint": REGION} + s3.create_bucket(**create_args) + return bucket_name + + +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) + + # Create zip of app directory + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: + tmp_path = tmp.name + try: + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _dirs, files in os.walk(APP_DIR): + for f in files: + if f == "Dockerfile": + continue + full = os.path.join(root, f) + arcname = os.path.relpath(full, APP_DIR) + zf.write(full, arcname) + + s3_key = f"{prefix}/code.zip" + print(f"Uploading code to s3://{bucket_name}/{s3_key}") + s3.upload_file(tmp_path, bucket_name, s3_key) + print("Upload complete") + return bucket_name, s3_key + finally: + os.unlink(tmp_path) + + +def get_account_id(): + sts = boto3.client("sts", region_name=REGION) + return sts.get_caller_identity()["Account"] + + +def get_control_client(): + return boto3.client("bedrock-agentcore-control", region_name=REGION) + + +def ensure_role(): + """Create the bugbash IAM role if it doesn't exist, with all needed permissions. + + This role is intentionally persistent across test runs — ensure_role() is + idempotent (create-if-not-exists) so multiple CI jobs and local debugging + sessions share the same role without conflicts. + """ + account_id = get_account_id() + role_name = "bugbash-agentcore-role" + role_arn = f"arn:aws:iam::{account_id}:role/{role_name}" + bucket_name = f"bugbash-agentcore-code-{account_id}-{REGION}" + + iam = boto3.client("iam") + created = False + try: + iam.get_role(RoleName=role_name) + print(f"IAM role already exists: {role_arn}") + except iam.exceptions.NoSuchEntityException: + print(f"Creating IAM role: {role_name}") + try: + iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "bedrock-agentcore.amazonaws.com"}, + "Action": "sts:AssumeRole", + }], + }), + ) + created = True + except iam.exceptions.EntityAlreadyExistsException: + print("Role was created by another process, waiting for propagation...") + created = True + + # Attach managed policies + managed_policies = [ + "arn:aws:iam::aws:policy/AmazonBedrockFullAccess", + "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", + "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess", + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + ] + for policy_arn in managed_policies: + try: + iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + except Exception: + pass # Already attached + + # Add inline policy for S3 code bucket and ECR auth + inline_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:GetBucketLocation", "s3:ListBucket"], + "Resource": [ + f"arn:aws:s3:::{bucket_name}", + f"arn:aws:s3:::{bucket_name}/*", + ], + }, + { + "Effect": "Allow", + "Action": [ + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:GetAuthorizationToken", + ], + "Resource": "*", + }, + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + ], + "Resource": "*", + }, + ], + } + try: + iam.put_role_policy( + RoleName=role_name, + PolicyName=INLINE_POLICY_NAME, + PolicyDocument=json.dumps(inline_policy), + ) + print("Inline permissions policy attached") + except Exception as e: + print(f"Warning: could not attach inline policy: {e}") + + if created: + print("Waiting 10s for role propagation...") + time.sleep(10) + + return role_arn + + +def wait_for_runtime(client, runtime_id, timeout=300): + """Wait for a runtime to reach READY status.""" + print(f"Waiting for runtime {runtime_id} to become READY...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_agent_runtime(agentRuntimeId=runtime_id) + status = resp.get("status", "UNKNOWN") + if status == "READY": + print(f"Runtime {runtime_id} is READY") + return True + if status in ("CREATE_FAILED", "UPDATE_FAILED", "FAILED"): + print(f"ERROR: Runtime {runtime_id} status: {status}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Runtime did not reach READY after {timeout}s") + return False + + +def wait_for_memory(client, memory_id, timeout=300): + """Wait for a memory to reach ACTIVE status.""" + print(f"Waiting for memory {memory_id} to become ACTIVE...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_memory(memoryId=memory_id) + status = resp.get("memory", {}).get("status", "UNKNOWN") + if status == "ACTIVE": + print(f"Memory {memory_id} is ACTIVE") + return True + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Memory did not reach ACTIVE after {timeout}s") + return False + + +def wait_for_evaluator(client, evaluator_id, timeout=120): + """Wait for an evaluator to reach ACTIVE status.""" + print(f"Waiting for evaluator {evaluator_id} to become ACTIVE...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_evaluator(evaluatorId=evaluator_id) + status = resp.get("status", "UNKNOWN") + if status == "ACTIVE": + print(f"Evaluator {evaluator_id} is ACTIVE") + return True + if status in ("CREATE_FAILED", "FAILED"): + print(f"ERROR: Evaluator {evaluator_id} status: {status}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Evaluator did not reach ACTIVE after {timeout}s") + return False + + +def save_resource(key, arn, resource_id): + """Save a resource entry to bugbash-resources.json.""" + resources = {} + if os.path.exists(RESOURCES_FILE): + with open(RESOURCES_FILE) as f: + resources = json.load(f) + resources[key] = {"arn": arn, "id": resource_id} + with open(RESOURCES_FILE, "w") as f: + json.dump(resources, f, indent=2) + print(f"Saved {key} to {RESOURCES_FILE}") + + +def print_import_command(resource_type, arn, extra_flags=""): + """Print the agentcore import command for the tester.""" + print() + print("=" * 50) + print("To test import, run (from an agentcore project directory):") + print() + print(f" export AWS_REGION={REGION}") + if resource_type == "runtime": + print(f" agentcore import runtime --arn {arn} --code {APP_DIR} {extra_flags}") + elif resource_type == "evaluator": + print(f" agentcore import evaluator --arn {arn} {extra_flags}") + else: + print(f" agentcore import memory --arn {arn} {extra_flags}") + print() + print("NOTE: The project must have aws-targets.json with a target for the same region.") + print("=" * 50) + print() + + +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) diff --git a/e2e-tests/fixtures/import/setup_evaluator.py b/e2e-tests/fixtures/import/setup_evaluator.py new file mode 100644 index 000000000..d49787d0e --- /dev/null +++ b/e2e-tests/fixtures/import/setup_evaluator.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Setup: LLM-as-a-Judge evaluator with rating scale and tags. + +Tests: evaluator import, level detection, llmAsAJudge config, rating scale, + model config, tags. +""" +import sys +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, +) + +DEFAULT_EVALUATOR_MODEL = os.environ.get("DEFAULT_EVALUATOR_MODEL", "us.anthropic.claude-sonnet-4-5-20250929-v1:0") + + +def main(): + client = get_control_client() + ts = int(time.time()) + evaluator_name = f"bugbash_eval_{ts}" + + print(f"Creating evaluator: {evaluator_name}") + resp = client.create_evaluator( + evaluatorName=evaluator_name, + description="Bugbash evaluator for import testing", + level="SESSION", + evaluatorConfig={ + "llmAsAJudge": { + "instructions": ( + "Evaluate the quality of the agent's response in this session.\n" + "Consider the following criteria:\n" + "1. Did the agent answer the user's question? ({context})\n" + "2. Was the response accurate and helpful?\n" + "3. Was the response well-structured?" + ), + "ratingScale": { + "numerical": [ + {"value": 1, "label": "Poor", "definition": "Response is irrelevant or incorrect"}, + {"value": 2, "label": "Fair", "definition": "Response is partially correct but missing key information"}, + {"value": 3, "label": "Good", "definition": "Response is correct and addresses the question"}, + {"value": 4, "label": "Very Good", "definition": "Response is thorough and well-structured"}, + {"value": 5, "label": "Excellent", "definition": "Response is comprehensive, accurate, and exceptionally helpful"}, + ], + }, + "modelConfig": { + "bedrockEvaluatorModelConfig": { + "modelId": DEFAULT_EVALUATOR_MODEL, + } + }, + } + }, + ) + + evaluator_id = resp["evaluatorId"] + evaluator_arn = resp["evaluatorArn"] + print(f"Evaluator ID: {evaluator_id}") + print(f"Evaluator ARN: {evaluator_arn}") + + tag_resource(client, evaluator_arn, { + "env": "bugbash", + "team": "agentcore-cli", + }) + + save_resource("evaluator-llm", evaluator_arn, evaluator_id) + if not wait_for_evaluator(client, evaluator_id): + sys.exit(1) + + print() + print("Expected fields after import:") + print(f" name: {evaluator_name}") + print(" level: SESSION") + print(" description: Bugbash evaluator for import testing") + print(f" config.llmAsAJudge.model: {DEFAULT_EVALUATOR_MODEL}") + print(" config.llmAsAJudge.instructions: (multi-line with {context} placeholder)") + print(" config.llmAsAJudge.ratingScale: numerical 1-5 (Poor to Excellent)") + print(" tags: {env: bugbash, team: agentcore-cli}") + + print_import_command("evaluator", evaluator_arn) + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/fixtures/import/setup_memory_full.py b/e2e-tests/fixtures/import/setup_memory_full.py new file mode 100644 index 000000000..5df196524 --- /dev/null +++ b/e2e-tests/fixtures/import/setup_memory_full.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Setup: Memory with multiple strategies and tags. + +Tests: memory import with SEMANTIC, SUMMARIZATION, USER_PREFERENCE strategies, + tags, eventExpiryDuration, executionRoleArn. +""" +import sys +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, +) + + +def main(): + role_arn = ensure_role() + client = get_control_client() + memory_name = f"bugbash_memory_{int(time.time())}" + + print(f"Creating memory: {memory_name}") + resp = client.create_memory( + name=memory_name, + clientToken=f"bugbash-{int(time.time())}", + eventExpiryDuration=30, + memoryExecutionRoleArn=role_arn, + memoryStrategies=[ + { + "semanticMemoryStrategy": { + "name": "bugbash_semantic", + "description": "Semantic strategy for bugbash testing", + "namespaces": ["default"], + } + }, + { + "summaryMemoryStrategy": { + "name": "bugbash_summary", + "description": "Summary strategy for bugbash testing", + } + }, + { + "userPreferenceMemoryStrategy": { + "name": "bugbash_userpref", + "description": "User preference strategy for bugbash testing", + } + }, + ], + ) + + memory_id = resp["memory"]["id"] + memory_arn = resp["memory"]["arn"] + print(f"Memory ID: {memory_id}") + print(f"Memory ARN: {memory_arn}") + + tag_resource(client, memory_arn, { + "env": "bugbash", + "team": "agentcore-cli", + }) + + save_resource("memory-full", memory_arn, memory_id) + if not wait_for_memory(client, memory_id): + sys.exit(1) + + print() + print("Expected fields after import:") + print(f" eventExpiryDuration: 30") + print(f" executionRoleArn: {role_arn}") + print(" strategies:") + print(" - type: SEMANTIC, name: bugbash_semantic, namespaces: [default]") + print(" - type: SUMMARIZATION, name: bugbash_summary") + print(" - type: USER_PREFERENCE, name: bugbash_userpref") + print(" tags: {env: bugbash, team: agentcore-cli}") + + print_import_command("memory", memory_arn) + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/fixtures/import/setup_runtime_basic.py b/e2e-tests/fixtures/import/setup_runtime_basic.py new file mode 100644 index 000000000..65e1585a1 --- /dev/null +++ b/e2e-tests/fixtures/import/setup_runtime_basic.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Setup: Bare minimum CodeZip runtime (PUBLIC, HTTP, basic entrypoint). + +Tests: baseline import, entrypoint detection, CodeZip build type, executionRoleArn. +""" +import sys +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, +) + + +def main(): + role_arn = ensure_role() + client = get_control_client() + ts = int(time.time()) + runtime_name = f"bugbash_basic_{ts}" + + bucket, s3_key = upload_code(f"bugbash-basic-{ts}") + + print(f"Creating basic runtime: {runtime_name}") + resp = client.create_agent_runtime( + agentRuntimeName=runtime_name, + roleArn=role_arn, + networkConfiguration={"networkMode": "PUBLIC"}, + agentRuntimeArtifact={ + "codeConfiguration": { + "code": { + "s3": { + "bucket": bucket, + "prefix": s3_key, + } + }, + "runtime": "PYTHON_3_12", + "entryPoint": ["main.py"], + } + }, + protocolConfiguration={"serverProtocol": "HTTP"}, + ) + + runtime_id = resp["agentRuntimeId"] + runtime_arn = resp["agentRuntimeArn"] + print(f"Runtime ID: {runtime_id}") + print(f"Runtime ARN: {runtime_arn}") + + save_resource("runtime-basic", runtime_arn, runtime_id) + if not wait_for_runtime(client, runtime_id): + sys.exit(1) + + print() + print("Expected fields after import:") + print(" build: CodeZip") + print(" entrypoint: main.py") + print(" runtimeVersion: PYTHON_3_12") + print(" protocol: HTTP") + print(" networkMode: PUBLIC") + print(f" executionRoleArn: {role_arn}") + print(" lifecycleConfiguration: defaults (idleRuntimeSessionTimeout=900, maxLifetime=28800)") + print(" (no envVars, tags, or requestHeaderAllowlist)") + + print_import_command("runtime", runtime_arn) + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/import-resources.test.ts b/e2e-tests/import-resources.test.ts new file mode 100644 index 000000000..e97f62f42 --- /dev/null +++ b/e2e-tests/import-resources.test.ts @@ -0,0 +1,217 @@ +import { DEFAULT_MODEL as DEFAULT_EVALUATOR_MODEL } from '../src/cli/tui/screens/evaluator/types.js'; +import { + type RunResult, + hasAwsCredentials, + hasCommand, + parseJsonOutput, + prereqs, + retry, + spawnAndCollect, + stripAnsi, +} from '../src/test-utils/index.js'; +import { 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 runtime/memory/evaluator', () => { + const region = process.env.AWS_REGION ?? 'us-east-1'; + const fixtureDir = join(__dirname, 'fixtures', 'import'); + const appDir = join(fixtureDir, 'app'); + const suffix = Date.now().toString().slice(-8); + const agentName = `E2eImp${suffix}`; + + let runtimeArn: string; + let memoryArn: string; + let evaluatorArn: string; + let projectPath: string; + let testDir: string; + + beforeAll(async () => { + if (!canRun) return; + + // 1. Run Python setup scripts to create AWS resources via API. + // Each script creates a resource and saves its ARN/ID to bugbash-resources.json. + // Scripts run sequentially because save_resource() does a read-modify-write + // on a shared bugbash-resources.json file — parallel runs would race. + for (const script of ['setup_runtime_basic.py', 'setup_memory_full.py', 'setup_evaluator.py']) { + const result = await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', script], fixtureDir, { + AWS_REGION: region, + DEFAULT_EVALUATOR_MODEL, + }); + if (result.exitCode !== 0) { + throw new Error( + `${script} failed (exit ${result.exitCode}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } + } + + // 2. Read resource ARNs from bugbash-resources.json + const resourcesPath = join(fixtureDir, 'bugbash-resources.json'); + const resources = JSON.parse(await readFile(resourcesPath, 'utf-8')) as Record; + runtimeArn = resources['runtime-basic']!.arn; + memoryArn = resources['memory-full']!.arn; + evaluatorArn = resources['evaluator-llm']!.arn; + + // 3. Create a destination CLI project (no agent — we'll import one) + testDir = join(tmpdir(), `agentcore-e2e-import-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runAgentCoreCLI( + ['create', '--name', agentName, '--no-agent', '--defaults', '--skip-git', '--skip-python-setup', '--json'], + testDir + ); + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; + + // 4. Configure deployment target + CDK + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 600_000); + + // Note: we don't use teardownE2EProject() here because import tests need + // extra cleanup — the Python fallback script deletes resources that weren't + // successfully imported into CloudFormation, and cleans up S3 code objects. + afterAll(async () => { + // 1. Tear down CFN stack created by import (this deletes all imported resources) + 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); + } + } + + // 2. Fallback: delete any resources that weren't imported into CFN + try { + await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', 'cleanup_resources.py'], fixtureDir, { + AWS_REGION: region, + }); + } catch { + /* ignore — resources may already be deleted by CFN teardown */ + } + + // 3. Clean up temp directory + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600_000); + + const run = (args: string[]): Promise => runAgentCoreCLI(args, projectPath); + + // ── Import tests ────────────────────────────────────────────────── + + it.skipIf(!canRun)( + 'imports a runtime by ARN', + async () => { + 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); + } + + expect(result.exitCode, `Import runtime failed: ${result.stderr}`).toBe(0); + expect(stripAnsi(result.stdout).toLowerCase()).toContain('imported successfully'); + }, + 600_000 + ); + + it.skipIf(!canRun)( + 'imports a memory by ARN', + async () => { + 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); + } + + expect(result.exitCode, `Import memory failed: ${result.stderr}`).toBe(0); + expect(stripAnsi(result.stdout).toLowerCase()).toContain('imported successfully'); + }, + 600_000 + ); + + it.skipIf(!canRun)( + 'imports an evaluator by ARN', + async () => { + 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); + } + + expect(result.exitCode, `Import evaluator failed: ${result.stderr}`).toBe(0); + expect(stripAnsi(result.stdout).toLowerCase()).toContain('imported successfully'); + }, + 600_000 + ); + + // ── Verification tests ──────────────────────────────────────────── + + it.skipIf(!canRun)( + 'status shows all imported resources 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 agent = json.resources.find(r => r.resourceType === 'agent'); + expect(agent, 'Imported runtime should appear in status').toBeDefined(); + expect(agent!.deploymentState).toBe('deployed'); + + const memory = json.resources.find(r => r.resourceType === 'memory'); + expect(memory, 'Imported memory should appear in status').toBeDefined(); + + const evaluator = json.resources.find(r => r.resourceType === 'evaluator'); + expect(evaluator, 'Imported evaluator should appear in status').toBeDefined(); + }, + 120_000 + ); + + it.skipIf(!canRun)( + 'invokes the imported runtime', + async () => { + await retry( + async () => { + const result = await run(['invoke', '--prompt', 'Say hello', '--runtime', agentName, '--json']); + + if (result.exitCode !== 0) { + console.log('Invoke stdout:', result.stdout); + console.log('Invoke stderr:', result.stderr); + } + + expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success, 'Invoke should report success').toBe(true); + }, + 3, + 15_000 + ); + }, + 180_000 + ); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 27d45a4f3..5111978cb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,80 @@ import reactRefresh from 'eslint-plugin-react-refresh'; import security from 'eslint-plugin-security'; import tseslint from 'typescript-eslint'; +/** @type {import('eslint').ESLint.Plugin} */ +const partitionPlugin = { + rules: { + 'no-hardcoded-arn-partition': { + meta: { + type: 'problem', + docs: { + description: 'Disallow hardcoded arn:aws: partition in ARN construction. Use arnPrefix(region) instead.', + }, + schema: [], + }, + create(context) { + function checkForHardcodedArn(node, value) { + if (/arn:aws:/.test(value)) { + context.report({ + node, + message: + 'Hardcoded "arn:aws:" detected. Use arnPrefix(region) from src/cli/aws/partition.ts for multi-partition support.', + }); + } + } + return { + TemplateLiteral(node) { + for (const quasi of node.quasis) { + checkForHardcodedArn(node, quasi.value.raw); + } + }, + }; + }, + }, + 'no-hardcoded-endpoint-tld': { + meta: { + type: 'problem', + docs: { + description: + 'Disallow hardcoded amazonaws.com in endpoint URL construction. Use serviceEndpoint() or dnsSuffix() instead.', + }, + schema: [], + }, + create(context) { + const REGION_PATTERN = /[a-z]{2}(-[a-z]+-\d+)/; + function hasHardcodedEndpoint(value) { + return /\.amazonaws\.com/.test(value); + } + function hasHardcodedEndpointWithRegion(value) { + return hasHardcodedEndpoint(value) && REGION_PATTERN.test(value); + } + return { + TemplateLiteral(node) { + for (const quasi of node.quasis) { + if (hasHardcodedEndpoint(quasi.value.raw)) { + context.report({ + node, + message: + 'Hardcoded ".amazonaws.com" in template literal. Use serviceEndpoint() or dnsSuffix() from src/cli/aws/partition.ts for multi-partition support.', + }); + } + } + }, + Literal(node) { + if (typeof node.value === 'string' && hasHardcodedEndpointWithRegion(node.value)) { + context.report({ + node, + message: + 'Hardcoded endpoint with region detected. Use serviceEndpoint() or dnsSuffix() from src/cli/aws/partition.ts for multi-partition support.', + }); + } + }, + }; + }, + }, + }, +}; + export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, @@ -30,8 +104,11 @@ export default tseslint.config( 'react-hooks': reactHooks, 'react-refresh': reactRefresh, security, + partition: partitionPlugin, }, rules: { + 'partition/no-hardcoded-arn-partition': 'error', + 'partition/no-hardcoded-endpoint-tld': 'error', ...importPlugin.configs.recommended.rules, ...react.configs.recommended.rules, ...security.configs.recommended.rules, @@ -66,8 +143,10 @@ 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', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-call': 'off', @@ -75,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 06510dfe8..0bdaa8bd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@aws/agentcore-dev", - "version": "0.8.0-dev", + "name": "@aws/agentcore", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@aws/agentcore-dev", - "version": "0.8.0-dev", + "name": "@aws/agentcore", + "version": "0.11.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -24,7 +24,13 @@ "@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", "@commander-js/extra-typings": "^14.0.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", @@ -39,13 +45,14 @@ "zod": "^4.3.5" }, "bin": { - "agentcore-dev": "dist/cli/index.mjs" + "agentcore": "dist/cli/index.mjs" }, "devDependencies": { "@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", @@ -56,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", @@ -70,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", @@ -84,6 +91,23 @@ "constructs": "^10.0.0" } }, + "node_modules/@ag-ui/core": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.52.tgz", + "integrity": "sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==", + "dependencies": { + "zod": "^3.22.4" + } + }, + "node_modules/@ag-ui/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", @@ -124,19 +148,19 @@ "license": "Apache-2.0" }, "node_modules/@aws-cdk/aws-service-spec": { - "version": "0.1.171", - "resolved": "https://registry.npmjs.org/@aws-cdk/aws-service-spec/-/aws-service-spec-0.1.171.tgz", - "integrity": "sha512-v03JmOpAXM7UCOmCggX1UIt8P1DVwr8RrG+Cl1Z6HjSxAFU/6dlNVrCAjfwkA26kX71YCTPOEvoiQNCEH11/DQ==", + "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.237", + "@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.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.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" @@ -218,9 +242,9 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "53.14.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.14.0.tgz", - "integrity": "sha512-prx2sbFfKrVf3NNXMOmWq6lsIBeWQDIqMTILLAddGiidn9j7OnLUubknWpJlLozMveTvQELdI++nUwq6jwCm9g==", + "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" @@ -254,15 +278,15 @@ } }, "node_modules/@aws-cdk/cloudformation-diff": { - "version": "2.187.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloudformation-diff/-/cloudformation-diff-2.187.0.tgz", - "integrity": "sha512-nxz7WvCWP1gTyWW/s/TP4kTSvgPmvjnx1cw5xSV+luOTpgFivYD0bYR102t1FpzzZmG2Ab0tDWu3lrLmON0/WA==", + "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.167", - "@aws-cdk/service-spec-types": "^0.0.233", + "@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" @@ -275,27 +299,27 @@ } }, "node_modules/@aws-cdk/cx-api": { - "version": "2.248.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cx-api/-/cx-api-2.248.0.tgz", - "integrity": "sha512-Vyh2Ks3/O7h1vS6tztyIrqGYQ6/ph0M7BRVtz0f5z0HPpA1eZ07WbwKx2rzxFmLrmJGh0CZkptmeJoaVLKkKeA==", + "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" @@ -304,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": { @@ -322,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": { @@ -344,23 +368,23 @@ } }, "node_modules/@aws-cdk/service-spec-types": { - "version": "0.0.233", - "resolved": "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.233.tgz", - "integrity": "sha512-ETtXnWvVUtGL9W7VrdPL/RDuEwCBgtWpohi+/XY7YLmugAfkRVAJvCW2VEiHgZarenK0Z9thc2jk7DIvKawEyQ==", + "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.21.2", - "resolved": "https://registry.npmjs.org/@aws-cdk/toolkit-lib/-/toolkit-lib-1.21.2.tgz", - "integrity": "sha512-CkIdWeeBz7pJRnd98Slw/zrLebnugXwT3HsKgILlM+IbD3pHk1xLiabRJDDNBVyFoxDjLaWTn2o3q4ZA4U2BxA==", + "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.1", - "@aws-cdk/cloud-assembly-schema": ">=53.14.0", + "@aws-cdk/cloud-assembly-api": "2.2.2", + "@aws-cdk/cloud-assembly-schema": ">=53.18.0", "@aws-cdk/cloudformation-diff": "^2", "@aws-cdk/cx-api": "^2", "@aws-sdk/client-appsync": "^3", @@ -391,12 +415,12 @@ "@smithy/util-retry": "^4", "@smithy/util-waiter": "^4", "archiver": "^7.0.1", - "cdk-from-cfn": "^0.293.0", + "cdk-from-cfn": "^0.295.0", "chalk": "^4", "chokidar": "^4", "fast-deep-equal": "^3.1.3", "fast-glob": "^3.3.3", - "fs-extra": "^9", + "fs-extra": "^11", "p-limit": "^3", "picomatch": "^4", "semver": "^7.7.4", @@ -412,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", @@ -624,48 +687,48 @@ } }, "node_modules/@aws-sdk/client-application-signals": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-application-signals/-/client-application-signals-3.1028.0.tgz", - "integrity": "sha512-f7DPZr/Z4kkky3n/bLJOLfylfK/ge6nkDAon/WbGVdvIa5nvk2HDRZtX1ZD2b5801HGWY3t4rD/0ZmB8vu0fwA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -674,49 +737,49 @@ } }, "node_modules/@aws-sdk/client-appsync": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-appsync/-/client-appsync-3.1028.0.tgz", - "integrity": "sha512-cVDgwD95waZL8GanyABtWXrjKfajGVzUG5TGvfX9NU4ComFPO8Eys6EGBO6sl8d6+1BJJuW6lIYjv24v+QNYeA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-stream": "^4.5.22", + "@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", "tslib": "^2.6.2" }, @@ -725,49 +788,49 @@ } }, "node_modules/@aws-sdk/client-bedrock": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1028.0.tgz", - "integrity": "sha512-YEUikjoImgUjv2UEpnD/WP0JiLdoLRnkajnSQR9LPCa8+BGy3+j879jimPlAuypOux1/CgqMA7Fwt13IpF2+UA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/token-providers": "3.1028.0", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.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.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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -776,48 +839,48 @@ } }, "node_modules/@aws-sdk/client-bedrock-agent": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent/-/client-bedrock-agent-3.1028.0.tgz", - "integrity": "sha512-9fqdTiGNVckbo/JGQI8w2+LpwQojh5W4GPNRdrT6iNT0Jg/3nelFwWsYPoqRde7mjJvppPvaqiIJZyAiaOAMiA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -826,52 +889,52 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1028.0.tgz", - "integrity": "sha512-Bg49uKvOh+sLVSwPHix52fIuDJEp4bFuHrHpa9Bkiyc5et9NPl2nZyHuN8bq8PxvUFaCyzddecrPK5eWbdgMVw==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/eventstream-serde-config-resolver": "^4.3.13", - "@smithy/eventstream-serde-node": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-stream": "^4.5.22", + "@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", "tslib": "^2.6.2" }, @@ -880,50 +943,50 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1028.0.tgz", - "integrity": "sha512-N726Jmh4qNjdski9Kzkba1W/map4XVDHB/wIHG3ECRiH1HOPkPmUnz9+AYqgRHHrcWUkU4iWng2TegGHL4b/4Q==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -931,56 +994,56 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1028.0.tgz", - "integrity": "sha512-FFdtkxWFmKX1Ka/vjDRKpYsm0/HTlab5qpHl8LAXRmJjhSSiLGiCnJYsYFN+zp3NucL02kM1DlpFU8Xnm7d8Ng==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/eventstream-handler-node": "^3.972.13", - "@aws-sdk/middleware-eventstream": "^3.972.9", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/middleware-websocket": "^3.972.15", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/token-providers": "3.1028.0", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/eventstream-serde-config-resolver": "^4.3.13", - "@smithy/eventstream-serde-node": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.35", + "@aws-sdk/middleware-websocket": "^3.972.16", + "@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.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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-stream": "^4.5.22", + "@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", "tslib": "^2.6.2" }, @@ -988,51 +1051,87 @@ "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.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudcontrol/-/client-cloudcontrol-3.1028.0.tgz", - "integrity": "sha512-2Ff+WHNYFqUMQPJ/um9IyBGK38gkHxUnILjpR5sV7Gs+VCXFOSbpmfRQlPGvcEQmpcFXMfEF5BxcI9V3GHLjDw==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1040,50 +1139,50 @@ } }, "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1028.0.tgz", - "integrity": "sha512-lcPKh2l2ohHX3NR5YECmDKcPaTAJVfxuuwABlnvWX0P7ChhYokptshaJ3HURbFlvHXpJNgAZ45C18X8cce+kkg==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1091,51 +1190,51 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1028.0.tgz", - "integrity": "sha512-E1abm/yXlUKeAOuLx9JZ+NFwhCkC0uWE9GmNF+a2+XvOkepoMbzZfP9btRm3oc7yGaqQO2vpa2oaBRngtAP/RQ==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/eventstream-serde-config-resolver": "^4.3.13", - "@smithy/eventstream-serde-node": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1144,48 +1243,48 @@ } }, "node_modules/@aws-sdk/client-codebuild": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-codebuild/-/client-codebuild-3.1028.0.tgz", - "integrity": "sha512-2IDGL0X8EsDv9mZOdH/9bxszUIgUq9ohS6rMek5PAeNAIUdfmlbMGPF//Vts8yT8u9nVN/GfaKo9J8durjoB5g==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1194,48 +1293,48 @@ } }, "node_modules/@aws-sdk/client-cognito-identity": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1028.0.tgz", - "integrity": "sha512-T94NnSifr6PPd66exnzK+QS7tQfo/tTw7ZvkHh6FmcepK4mFNm12s5OEIu/XYiVWSX4RaBCwYIAZvcjqXnWHGQ==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1244,49 +1343,49 @@ } }, "node_modules/@aws-sdk/client-cognito-identity-provider": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1028.0.tgz", - "integrity": "sha512-UpTH9ljQl+sYnNl/YWHsvYSZnNkMsEEh2gGdzAftg8c4/ywor8ZdT6z3rSRB/e1xdvf386QBgz2QSeBiyvTkbQ==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1295,51 +1394,51 @@ } }, "node_modules/@aws-sdk/client-ec2": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ec2/-/client-ec2-3.1028.0.tgz", - "integrity": "sha512-q34bnOXqdNGEvmGP1GbaQ43O2EhliQGOpNlW02AozRBZ3mEfYovjr2HeC0QXmdSqgDVg4bqlBbyBX459juyJSQ==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-sdk-ec2": "^3.972.19", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.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.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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1347,50 +1446,50 @@ } }, "node_modules/@aws-sdk/client-ecr": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1028.0.tgz", - "integrity": "sha512-NvnqmQxFtBC6eQKW5leLIfE8Go7GgBlIiFb9sfCVEMXg4i9rYAlb/Sd4reiH0AdWY/L2p+fCqOsG7Dw/DVwCoQ==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1398,50 +1497,50 @@ } }, "node_modules/@aws-sdk/client-ecs": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecs/-/client-ecs-3.1028.0.tgz", - "integrity": "sha512-4V3I21ZpKYKG8QnROIm/ajIV623A0sOVf+XLpDYxVod1/3Y0q+eSB6QtNYyEZg1u8YUr6zJWF4cWvOqcEocA3w==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1449,50 +1548,50 @@ } }, "node_modules/@aws-sdk/client-elastic-load-balancing-v2": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-elastic-load-balancing-v2/-/client-elastic-load-balancing-v2-3.1028.0.tgz", - "integrity": "sha512-jdu5nINVKxce1pEL7hhhA5+jOUONvJRSgUtc7UAeVWHoap5i9HVCmY2wAseem4AW5OO7WKlHFnIE2QODTyCwag==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1500,50 +1599,50 @@ } }, "node_modules/@aws-sdk/client-iam": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1028.0.tgz", - "integrity": "sha512-4Sl0ibZqpBW2zZEbLRDUF6cQD+ywwT59yXifUHqEh724AOZKtll6IFyXjQI4BSzI2LjH/enlXcyajm5MFDFJJw==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1551,48 +1650,48 @@ } }, "node_modules/@aws-sdk/client-kms": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1028.0.tgz", - "integrity": "sha512-M3ahMP9HAuklAX3u3KvyotryrF/S+wr37hOE+P47EOM64XV85tRoy4+5x8hD5lUIEa1XMEtqrkC0VrXxdyFFBw==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1601,54 +1700,54 @@ } }, "node_modules/@aws-sdk/client-lambda": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1028.0.tgz", - "integrity": "sha512-wAJT/1M5UT5d0Nw/7XTWzVO3nJpvghGxoiiKxR3KKIbOEIGPEY2FJpw5xZHFaLS7Du5sr9rCyqJ+0fiejID/gA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/eventstream-serde-config-resolver": "^4.3.13", - "@smithy/eventstream-serde-node": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-stream": "^4.5.22", + "@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", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1656,48 +1755,48 @@ } }, "node_modules/@aws-sdk/client-resource-groups-tagging-api": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-resource-groups-tagging-api/-/client-resource-groups-tagging-api-3.1028.0.tgz", - "integrity": "sha512-YXnDSo9nqLLhY43+Dda4D7hrDH3+NNuqRtre9+tREIXqLeNd1MDu3w09fmGLMRe4+zeQa3xmCVd2WTQE9NQskw==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1706,51 +1805,51 @@ } }, "node_modules/@aws-sdk/client-route-53": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-route-53/-/client-route-53-3.1028.0.tgz", - "integrity": "sha512-nIOamdVWefCAHytkRFoYwcJ77gSlrxOMCbN70tyaZTc8UEb133kMa2TZAPlperFOPd3zP1J8KSWzT3nA0WiPLA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-sdk-route53": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1758,65 +1857,65 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1028.0.tgz", - "integrity": "sha512-KL8PREFJxyWXUjMQR6Krq/OjZ5qbcV1QFjtA7Q7oMW5XaFO9YoSBtBxQeeXO4um6vYSmRVYVDTvEKZDcNbyeXw==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", - "@aws-sdk/middleware-expect-continue": "^3.972.9", - "@aws-sdk/middleware-flexible-checksums": "^3.974.7", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-location-constraint": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-sdk-s3": "^3.972.28", - "@aws-sdk/middleware-ssec": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/signature-v4-multi-region": "^3.996.16", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/eventstream-serde-config-resolver": "^4.3.13", - "@smithy/eventstream-serde-node": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-blob-browser": "^4.2.14", - "@smithy/hash-node": "^4.2.13", - "@smithy/hash-stream-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/md5-js": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.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.34", + "@aws-sdk/middleware-ssec": "^3.972.10", + "@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.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-blob-browser": "^4.2.15", + "@smithy/hash-node": "^4.2.14", + "@smithy/hash-stream-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@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-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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-stream": "^4.5.22", + "@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", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1824,48 +1923,48 @@ } }, "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1028.0.tgz", - "integrity": "sha512-Vj+pgAb8raFIxUh0WCFI3fhYY68lN1tGFPj+EFauB8EUbgz9BA3TuI8plFRXvq+h9m1gXvW/VbQTzCxkoeChdA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1874,48 +1973,48 @@ } }, "node_modules/@aws-sdk/client-sfn": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sfn/-/client-sfn-3.1028.0.tgz", - "integrity": "sha512-5SxAGn2KmWHx+L6fQXr+trblhSrUjQfNEOLQKV3ayJdaIIZDYe5QueUejHw60LWre5Ok9gYhDCKN9oZxVvM6RA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1924,50 +2023,50 @@ } }, "node_modules/@aws-sdk/client-ssm": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1028.0.tgz", - "integrity": "sha512-SaRwfqLe341Yj4fLMLWPqimnMdBLUfjskHI/LGwHuWI0SejCiRHnHgQqk8LqFcNyUW8HwgKxDqyDPIPPcSbeEg==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1975,48 +2074,49 @@ } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1028.0.tgz", - "integrity": "sha512-LA3P2ssZmHO/59UrJL4EQ/a/dqZUraZOoSW2WgngLa6EO8CfXNlDZACV6xiRGpHThxU5bnLQcQqsQoBYKxpcoA==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.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.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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2025,48 +2125,48 @@ } }, "node_modules/@aws-sdk/client-xray": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-xray/-/client-xray-3.1028.0.tgz", - "integrity": "sha512-IWlaWu/HlnoDSLF8YGE4gPXjd/UeYs87Ag88YxSl9XUqm7eFGMeEzz7YBhypqaP8cdttMsDXFWalAJzIPInj0Q==", + "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.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2075,22 +2175,23 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", - "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/xml-builder": "^3.972.17", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "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.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.13", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2099,12 +2200,12 @@ } }, "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.6.tgz", - "integrity": "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", + "integrity": "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2112,15 +2213,15 @@ } }, "node_modules/@aws-sdk/credential-provider-cognito-identity": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.22.tgz", - "integrity": "sha512-ih6ORpme4i2qJqGckOQ9Lt2iiZ+5tm3bnfsT5TwoPyFnuDURXv3OdhYa3Nr/m0iJr38biqKYKdGKb5GR1KB2hw==", + "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.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2128,15 +2229,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz", - "integrity": "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==", + "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.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2144,20 +2245,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz", - "integrity": "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==", + "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.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -2165,24 +2266,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz", - "integrity": "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==", + "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.973.27", - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-login": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@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", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2190,18 +2291,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz", - "integrity": "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==", + "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.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@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", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2209,22 +2310,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz", - "integrity": "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==", + "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.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-ini": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@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", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2232,16 +2333,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz", - "integrity": "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==", + "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.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.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", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2249,36 +2350,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz", - "integrity": "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/token-providers": "3.1026.0", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz", - "integrity": "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==", + "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.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.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", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2286,17 +2369,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz", - "integrity": "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==", + "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.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@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": { @@ -2304,30 +2387,30 @@ } }, "node_modules/@aws-sdk/credential-providers": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1028.0.tgz", - "integrity": "sha512-ceaO4TnRycUoJl/1hNCdwWJLKHVF4R82YtBY6QE2SF1JVkrnR/WZu/yF1n1fPLuvYQlXW7OIOT9df7t2bfXM2Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-cognito-identity": "3.1028.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-cognito-identity": "^3.972.22", - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-ini": "^3.972.29", - "@aws-sdk/credential-provider-login": "^3.972.29", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", + "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.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", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2335,17 +2418,17 @@ } }, "node_modules/@aws-sdk/ec2-metadata-service": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/ec2-metadata-service/-/ec2-metadata-service-3.1028.0.tgz", - "integrity": "sha512-OIM7TZnAG/AXN8izmoQlzZ9pI9lEQevDW/UuFUcfe7PBr/zrGPI222ezz5UCQRnIKCxxhK0I8JZqmH2Dbjo0wA==", + "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.7", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@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": { @@ -2353,14 +2436,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.13.tgz", - "integrity": "sha512-2Pi1kD0MDkMAxDHqvpi/hKMs9hXUYbj2GLEjCwy+0jzfLChAsF50SUYnOeTI+RztA+Ic4pnLAdB03f1e8nggxQ==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.14.tgz", + "integrity": "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/eventstream-codec": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2368,15 +2451,15 @@ } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1028.0.tgz", - "integrity": "sha512-AT937nfpMDW/8oDiWPBP/BdGJ6943ALMWTBpUi0fD0qelA3lyZgErSnX7yp9j3t/enzyHdlyBOPq9kGFBt0Xcg==", + "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.29", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@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", @@ -2386,20 +2469,20 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.1028.0" + "@aws-sdk/client-s3": "^3.1018.0" } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.9.tgz", - "integrity": "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz", + "integrity": "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -2408,14 +2491,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.9.tgz", - "integrity": "sha512-ypgOvpWxQTCnQyDHGxnTviqqANE7FIIzII7VczJnTPCJcJlu17hMQXnvE47aKSKsawVJAaaRsyOEbHQuLJF9ng==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.10.tgz", + "integrity": "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2423,14 +2506,14 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.9.tgz", - "integrity": "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz", + "integrity": "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2438,23 +2521,23 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.7.tgz", - "integrity": "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w==", + "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.973.27", - "@aws-sdk/crc64-nvme": "^3.972.6", - "@aws-sdk/types": "^3.973.7", + "@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", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2463,14 +2546,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", - "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2478,13 +2561,13 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.9.tgz", - "integrity": "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", + "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2492,13 +2575,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", - "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2506,15 +2589,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", - "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2522,18 +2605,18 @@ } }, "node_modules/@aws-sdk/middleware-sdk-ec2": { - "version": "3.972.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.972.19.tgz", - "integrity": "sha512-eB73yVCMipYwoxiKzRAy4gt1FiAVl/EodfdMxvPomKZw+yWEWKiGhwrVhtLHhFRAM+QkMLnEslsbvsyFELHW+g==", + "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.7", - "@aws-sdk/util-format-url": "^3.972.9", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-format-url": "^3.972.10", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2541,13 +2624,13 @@ } }, "node_modules/@aws-sdk/middleware-sdk-route53": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-route53/-/middleware-sdk-route53-3.972.11.tgz", - "integrity": "sha512-5nvSVRgcbxR677ON2+AAWPOJkyqyyjx9Pi9EBSNAkU/G7W4/tGPs9qxjBnuJx5YEGJX+XoTGz5qq0CWmXGJHkA==", + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-route53/-/middleware-sdk-route53-3.972.12.tgz", + "integrity": "sha512-nj08j4q/Rp8zb3SqwxE+dex22NdXoSKJAh445x0SLGAI23lYfDTujFDG1JRYLRc1uR2/FPPr76L/ki/VE4J9ig==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2555,23 +2638,23 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.28.tgz", - "integrity": "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg==", + "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.973.27", - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@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.13", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2580,13 +2663,13 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.9.tgz", - "integrity": "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", + "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2594,18 +2677,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", - "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "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.973.27", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-retry": "^4.3.0", + "@aws-sdk/core": "^3.974.5", + "@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", "tslib": "^2.6.2" }, "engines": { @@ -2613,19 +2696,19 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.15.tgz", - "integrity": "sha512-hsZ35FORQsN5hwNdMD6zWmHCphbXkDxO6j+xwCUiuMb0O6gzS/PWgttQNl1OAn7h/uqZAMUG4yOS0wY/yhAieg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-format-url": "^3.972.9", - "@smithy/eventstream-codec": "^4.2.13", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/types": "^4.14.0", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.16.tgz", + "integrity": "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-format-url": "^3.972.10", + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", @@ -2636,47 +2719,48 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", - "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "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.973.27", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@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.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.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/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.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@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-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2685,15 +2769,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", - "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "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.7", - "@smithy/config-resolver": "^4.4.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2701,16 +2785,16 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz", - "integrity": "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ==", + "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.28", - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/types": "^4.14.0", + "@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", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2718,17 +2802,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1028.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1028.0.tgz", - "integrity": "sha512-2vDFrEhJDlUHyvDxqDyOk97cejMM8GJDyQbFfOCEWclGwhTjlj1mdyj36xsxh7DYyuquhjqfbvhpl6ZzsVol0w==", + "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.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@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": { @@ -2736,12 +2820,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", - "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2761,15 +2845,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", - "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "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.7", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-endpoints": "^3.3.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -2777,14 +2861,14 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.9.tgz", - "integrity": "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", + "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2804,27 +2888,27 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", - "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", - "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "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.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@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", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -2854,6 +2938,28 @@ "node": ">=20.0.0" } }, + "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==", + "license": "Apache-2.0", + "dependencies": { + "@ag-ui/core": "^0.0.52", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-testing-library": "^7.16.0", + "lucide-react": "^0.575.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", @@ -2884,7 +2990,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -2899,7 +3004,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2909,7 +3013,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -2940,7 +3043,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2950,7 +3052,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -2967,7 +3068,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -2984,7 +3084,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2994,7 +3093,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3004,7 +3102,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -3018,7 +3115,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -3036,7 +3132,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3046,7 +3141,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3056,7 +3150,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3066,7 +3159,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -3080,7 +3172,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -3096,7 +3187,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -3111,7 +3201,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -3130,7 +3219,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -3166,9 +3254,9 @@ } }, "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==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -3178,9 +3266,9 @@ } }, "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==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -3200,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" ], @@ -3217,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" ], @@ -3234,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" ], @@ -3251,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" ], @@ -3268,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" ], @@ -3285,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" ], @@ -3302,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" ], @@ -3319,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" ], @@ -3336,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" ], @@ -3353,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" ], @@ -3370,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" ], @@ -3387,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" ], @@ -3404,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" ], @@ -3421,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" ], @@ -3438,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" ], @@ -3455,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" ], @@ -3472,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" ], @@ -3489,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" ], @@ -3506,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" ], @@ -3523,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" ], @@ -3540,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" ], @@ -3557,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" ], @@ -3574,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" ], @@ -3591,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" ], @@ -3608,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" ], @@ -3625,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" ], @@ -3645,7 +3733,6 @@ "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -3664,7 +3751,6 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -3674,7 +3760,6 @@ "version": "0.21.2", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", @@ -3689,7 +3774,6 @@ "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0" @@ -3702,7 +3786,6 @@ "version": "0.17.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -3715,7 +3798,6 @@ "version": "3.3.5", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.14.0", @@ -3739,7 +3821,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3756,7 +3837,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -3766,14 +3846,12 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3786,7 +3864,6 @@ "version": "2.1.7", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3796,7 +3873,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0", @@ -3823,7 +3899,6 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -3833,7 +3908,6 @@ "version": "0.16.7", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", @@ -3847,7 +3921,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -3861,7 +3934,6 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -3944,7 +4016,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3955,7 +4026,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3966,7 +4036,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3976,14 +4045,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4032,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": { @@ -4079,1021 +4152,1152 @@ "node": ">= 8" } }, - "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==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" } }, - "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, + "node_modules/@opentelemetry/api-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.213.0.tgz", + "integrity": "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, "engines": { - "node": ">=14" + "node": ">=8.0.0" } }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@opentelemetry/core": { + "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" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "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/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": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "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/api": "^1.3.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=8.0.0" } }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "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": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "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==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "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.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "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.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "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.1", + "@opentelemetry/otlp-transformer": "0.214.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "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==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "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": "^20.19.0 || >=22.12.0" + "node": ">=8.0.0" } }, - "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==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "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": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "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": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "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": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "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": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "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==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, + "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": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", - "dev": true, - "license": "MIT", - "optional": true, + "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": { - "@tybys/wasm-util": "^0.10.1" + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "engines": { + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "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" - ], + "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": "^20.19.0 || >=22.12.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "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": "^20.19.0 || >=22.12.0" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/config-creator": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-11.6.0.tgz", - "integrity": "sha512-Mwj9RKgmXB2+ErF0Wk/9SSPsOWYfv1FlHqLevLMSYF8wmZ5iXBCU0Uys/akux2X8alDe+dK4eoVi0UuBKoUBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@secretlint/types": "11.6.0" + "node": "^18.19.0 || >=20.6.0" }, - "engines": { - "node": ">=20.0.0" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@secretlint/config-loader": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-11.6.0.tgz", - "integrity": "sha512-IWNNnQ015M+b95VvsfBmJwPVO7U0TT2tBtVf67TMluULJx10qETkf0GDTnMVeN65sEc/C4zsoEL+TfCyObJMfw==", - "dev": true, - "license": "MIT", + "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": { - "@secretlint/profiler": "11.6.0", - "@secretlint/resolver": "11.6.0", - "@secretlint/types": "11.6.0", - "ajv": "^8.18.0", - "debug": "^4.4.3", - "rc-config-loader": "^4.1.4" + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@secretlint/core": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-11.6.0.tgz", - "integrity": "sha512-CGrXYXP4efvAv6VdSqu+mW2MxLL3ATrHgqQyVCFdA1tw34aafoaMHKSPQthiEj9RbPiYsGbiE2JmmauDHs0rEw==", - "dev": true, - "license": "MIT", + "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": { - "@secretlint/profiler": "11.6.0", - "@secretlint/types": "11.6.0", - "debug": "^4.4.3", - "structured-source": "^4.0.0" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@secretlint/formatter": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-11.6.0.tgz", - "integrity": "sha512-wdxR0t7YL/fDG8KsgucMzoMCHxjdIiOQnoDYYRELA7OINJqKNPILC6Xyq5pkv2WxEWdSrCfut09mugCyNja1zg==", - "dev": true, - "license": "MIT", + "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": { - "@secretlint/resolver": "11.6.0", - "@secretlint/types": "11.6.0", - "@textlint/linter-formatter": "^15.5.2", - "@textlint/module-interop": "^15.5.2", - "@textlint/types": "^15.5.2", - "chalk": "^5.6.2", - "debug": "^4.4.3", - "pluralize": "^8.0.0", - "strip-ansi": "^7.2.0", - "table": "^6.9.0", - "terminal-link": "^4.0.0" + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@secretlint/formatter/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", + "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": "^12.17.0 || ^14.13 || >=16.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "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", + "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": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14.18" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "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", + "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": { - "ansi-escapes": "^7.0.0", - "supports-hyperlinks": "^3.2.0" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@secretlint/node": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-11.6.0.tgz", - "integrity": "sha512-dpr9DwoLkGc82JcNBtqHFOgWmZxelRFHIy10hD/zBijjBdVBcxS7D39mML9lVENpsnci1UtbaECY8ymirN78MQ==", - "dev": true, - "license": "MIT", + "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": { - "@secretlint/config-loader": "11.6.0", - "@secretlint/core": "11.6.0", - "@secretlint/formatter": "11.6.0", - "@secretlint/profiler": "11.6.0", - "@secretlint/source-creator": "11.6.0", - "@secretlint/types": "11.6.0", - "debug": "^4.4.3", - "p-map": "^7.0.4" + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=20.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@secretlint/profiler": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-11.6.0.tgz", - "integrity": "sha512-ZwgjAIgrYv60LkCfBUTsdfMtROy+iiiwX7DvIcEoPlKFfKC7TEteE1liTgT4mdRg/+9IG5NA9aaOBLWG01byxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/resolver": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-11.6.0.tgz", - "integrity": "sha512-8paE/tXek7SRCr5zSmg0ZFdGgZm2eo0tPBq5XvClx/es4tYNAGbj8TvxQcgRD3q4tWG1Gf1GhD4LcS/mTmQgyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-11.6.0.tgz", - "integrity": "sha512-yEC93K7WWOYN3WBOs7/LwwtsX3LijNKVsR92+Aar3grqM/QfqBZBQQm06sU+gpg/HC9ndn0sd3ljrZe6jcPzcg==", - "dev": true, - "license": "MIT", + "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": ">=20.0.0" + "node": ">=14" } }, - "node_modules/@secretlint/source-creator": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-11.6.0.tgz", - "integrity": "sha512-iBm5/UOrT8WAc5/uzdNdOMU4hVbkkESr9O/OEmKnONhjjy4AVKd+6H50o3C/uJZVR9KQrixqs3qgDkSgCb4ebQ==", + "node_modules/@oxc-project/types": { + "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", - "dependencies": { - "@secretlint/types": "11.6.0", - "istextorbinary": "^9.5.0" - }, - "engines": { - "node": ">=20.0.0" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@secretlint/types": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-11.6.0.tgz", - "integrity": "sha512-ooPksY3/ad1qmHhIlnernIZI3yKO55PWsX+RCcj44l9VYSlQCZyfz5DnC/e0U5KxecI9P56iQxxNPkMU6nkUWg==", - "dev": true, + "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": ">=20.0.0" + "node": ">=14" } }, - "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==", - "dev": true, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "license": "MIT", "engines": { - "node": ">=18" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/pkgr" } }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", - "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "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": { - "tslib": "^2.6.2" + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", - "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", - "license": "Apache-2.0", + "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.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "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": { - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" } }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", - "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "tslib": "^2.6.2" - }, + "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.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "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.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/binding-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/core": { - "version": "3.23.14", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", - "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", - "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.13.tgz", - "integrity": "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.0", - "@smithy/util-hex-encoding": "^4.2.2", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.13.tgz", - "integrity": "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.13", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.13.tgz", - "integrity": "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.13.tgz", - "integrity": "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.13", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.13.tgz", - "integrity": "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.2.13", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", - "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.14.tgz", - "integrity": "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.2", - "@smithy/chunked-blob-reader-native": "^4.2.3", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/hash-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", - "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.0", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.13.tgz", - "integrity": "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.0", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", - "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", - "license": "Apache-2.0", + "node_modules/@rolldown/binding-wasm32-wasi": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "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, + "os": [ + "win32" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/md5-js": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.13.tgz", - "integrity": "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.0", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, + "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, + "os": [ + "win32" + ], "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", - "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@rolldown/pluginutils": { + "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" }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.29", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", - "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-middleware": "^4.2.13", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "license": "MIT" }, - "node_modules/@smithy/middleware-retry": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.1.tgz", - "integrity": "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/service-error-classification": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.1", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "12.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", - "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", - "license": "Apache-2.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==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" + "@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": ">=18.0.0" + "node": ">=22.0.0" } }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", - "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", - "license": "Apache-2.0", + "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==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" + "@secretlint/profiler": "12.2.0", + "@secretlint/types": "12.2.0", + "debug": "^4.4.3", + "structured-source": "^4.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", - "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", - "license": "Apache-2.0", + "node_modules/@secretlint/formatter": { + "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": { - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" + "@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", + "chalk": "^5.6.2", + "debug": "^4.4.3", + "pluralize": "^8.0.0", + "strip-ansi": "^7.2.0", + "table": "^6.9.0", + "terminal-link": "^5.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, - "node_modules/@smithy/node-http-handler": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", - "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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==", + "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", + "debug": "^4.4.3", + "p-map": "^7.0.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "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==", + "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==", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "12.2.0", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "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==", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/property-provider": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", - "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", - "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", + "node_modules/@smithy/config-resolver": { + "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/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", - "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", + "node_modules/@smithy/core": { + "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/types": "^4.14.0", - "@smithy/util-uri-escape": "^4.2.2", + "@smithy/protocol-http": "^5.3.14", + "@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-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", - "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", - "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0" + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", - "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", + "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", - "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", + "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", - "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", + "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", - "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", + "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", "license": "Apache-2.0", "dependencies": { + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/url-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", - "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz", + "integrity": "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", "license": "Apache-2.0", "dependencies": { + "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -5102,1302 +5306,2589 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz", + "integrity": "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", "license": "Apache-2.0", "dependencies": { + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-buffer-from": { + "node_modules/@smithy/is-array-buffer": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "node_modules/@smithy/md5-js": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.14.tgz", + "integrity": "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==", "license": "Apache-2.0", "dependencies": { + "@smithy/types": "^4.14.1", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.45", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", - "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", - "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", + "node_modules/@smithy/middleware-endpoint": { + "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/config-resolver": "^4.4.14", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@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", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-endpoints": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", - "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", + "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==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@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/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.4", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "node_modules/@smithy/middleware-serde": { + "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.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", - "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", + "node_modules/@smithy/middleware-stack": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-retry": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.1.tgz", - "integrity": "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==", + "node_modules/@smithy/node-config-provider": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.13", - "@smithy/types": "^4.14.0", + "@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": ">=18.0.0" } }, - "node_modules/@smithy/util-stream": { - "version": "4.5.22", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", - "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", + "node_modules/@smithy/node-http-handler": { + "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/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/types": "^4.14.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "node_modules/@smithy/property-provider": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "node_modules/@smithy/protocol-http": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-waiter": { - "version": "4.2.15", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.15.tgz", - "integrity": "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg==", + "node_modules/@smithy/querystring-builder": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "node_modules/@smithy/querystring-parser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "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==", - "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==", - "dev": true, - "license": "MIT", + "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==", + "license": "Apache-2.0", "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", - "chalk": "^4.1.2", - "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "lodash": "^4.18.1", - "pluralize": "^2.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "table": "^6.9.0", - "text-table": "^0.2.0" + "@smithy/types": "^4.14.1" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@textlint/linter-formatter/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==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "node_modules/@textlint/linter-formatter/node_modules/pluralize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", - "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter/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==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/signature-v4": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", + "license": "Apache-2.0", "dependencies": { - "ansi-regex": "^5.0.1" + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, - "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==", - "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==", - "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==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/smithy-client": { + "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": { - "@textlint/ast-node-types": "15.5.4" + "@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.25", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@trivago/prettier-plugin-sort-imports": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz", - "integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==", - "dev": true, + "node_modules/@smithy/types": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { - "@babel/generator": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", - "javascript-natural-sort": "^0.7.1", - "lodash-es": "^4.17.21", - "minimatch": "^9.0.0", - "parse-imports-exports": "^0.2.4" + "tslib": "^2.6.2" }, "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@vue/compiler-sfc": "3.x", - "prettier": "2.x - 3.x", - "prettier-plugin-ember-template-tag": ">= 2.0.0", - "prettier-plugin-svelte": "3.x", - "svelte": "4.x || 5.x" - }, - "peerDependenciesMeta": { - "@vue/compiler-sfc": { - "optional": true - }, - "prettier-plugin-ember-template-tag": { - "optional": true - }, - "prettier-plugin-svelte": { - "optional": true - }, - "svelte": { - "optional": true - } + "node": ">=18.0.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, + "node_modules/@smithy/url-parser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", "dependencies": { - "undici-types": "~7.18.0" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, - "license": "MIT", + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", "dependencies": { - "csstype": "^3.2.2" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", - "debug": "^4.4.3" + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-defaults-mode-browser": { + "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": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", - "debug": "^4.4.3" + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-defaults-mode-node": { + "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": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@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.13", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node_modules/@smithy/util-endpoints": { + "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", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node_modules/@smithy/util-middleware": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", - "dev": true, - "license": "MIT", + "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==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" + "@smithy/service-error-classification": "^4.3.0", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-stream": { + "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": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@smithy/fetch-http-handler": "^5.3.17", + "@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", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "eslint-visitor-keys": "^5.0.0" + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, + "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==", "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], + "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==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], + "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==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "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", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], + "node_modules/@textlint/linter-formatter/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==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], + "node_modules/@textlint/linter-formatter/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "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==", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.5.4" + } + }, + "node_modules/@trivago/prettier-plugin-sort-imports": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-6.0.2.tgz", + "integrity": "sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/generator": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "javascript-natural-sort": "^0.7.1", + "lodash-es": "^4.17.21", + "minimatch": "^9.0.0", + "parse-imports-exports": "^0.2.4" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@vue/compiler-sfc": "3.x", + "prettier": "2.x - 3.x", + "prettier-plugin-ember-template-tag": ">= 2.0.0", + "prettier-plugin-svelte": "3.x", + "svelte": "4.x || 5.x" + }, + "peerDependenciesMeta": { + "@vue/compiler-sfc": { + "optional": true + }, + "prettier-plugin-ember-template-tag": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + }, + "svelte": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "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.18.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "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.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.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "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.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": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "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.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "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.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "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.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "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.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.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "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.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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "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.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "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", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/coverage-v8": { + "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.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "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.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "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.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "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": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "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.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "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.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "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": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "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.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "bundleDependencies": [ + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "dev": true, + "license": "Apache-2.0", + "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", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.3", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.3", + "punycode": "^2.3.1", + "semver": "^7.7.4", + "table": "^6.9.0", + "yaml": "1.10.3" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.0", + "bundleDependencies": [ + "jsonschema", + "semver" ], "dev": true, + "inBundle": true, + "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.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { + "version": "1.4.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.18.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "18 || 20 || >=22" + } }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.5", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "inBundle": true, + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "inBundle": true, + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "inBundle": true, + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.3", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=14.14" } }, - "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", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">= 4" + } }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", "dev": true, + "inBundle": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "*" + } }, - "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==", + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", "dev": true, + "inBundle": true, "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.4", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.4", - "vitest": "4.1.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } + "engines": { + "node": ">= 0.6" } }, - "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==", + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", "dev": true, + "inBundle": 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", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "mime-db": "1.52.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 0.6" } }, - "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==", + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.5", "dev": true, - "license": "MIT", + "inBundle": true, + "license": "BlueOak-1.0.0", "dependencies": { - "@vitest/spy": "4.1.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "brace-expansion": "^5.0.5" }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "engines": { + "node": "18 || 20 || >=22" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "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==", + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", "dev": true, + "inBundle": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=6" } }, - "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==", + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", "dev": true, + "inBundle": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.4", - "pathe": "^2.0.3" + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=10" } }, - "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==", + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", "dev": true, + "inBundle": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" } }, - "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==", + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", "dev": true, + "inBundle": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=8" } }, - "node_modules/@xterm/headless": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", - "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", "dev": true, - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", + "inBundle": true, + "license": "BSD-3-Clause", "dependencies": { - "event-target-shim": "^5.0.0" + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=6.5" + "node": ">=10.0.0" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", "dev": true, + "inBundle": true, "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, "engines": { - "node": ">= 0.6" + "node": ">= 10.0.0" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.3", "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "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", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { - "node": ">=0.4.0" + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "node_modules/bare-os": { + "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" } }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "bare-os": "^3.0.1" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", + "node_modules/bare-stream": { + "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": { - "ajv": "^8.0.0" + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { - "ajv": "^8.0.0" + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" }, "peerDependenciesMeta": { - "ajv": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { "optional": true } } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "license": "MIT", + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", "dependencies": { - "environment": "^1.0.0" + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "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" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6.0.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, "engines": { - "node": ">=12" + "node": ">=4" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://bevry.me/fund" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "archiver-utils": "^5.0.2", - "async": "^3.2.4", - "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">= 14" + "node": "18 || 20 || >=22" } }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">= 14" + "node": ">=8" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" + "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" }, - "engines": { - "node": ">= 0.4" + "bin": { + "browserslist": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, + "node_modules/call-bind": { + "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": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" + "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": { "node": ">= 0.4" @@ -6406,39 +7897,27 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -6447,132 +7926,152 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "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", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "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==", + "license": "MIT OR Apache-2.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", "license": "MIT", - "engines": { - "node": ">=12" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "dev": true, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", "license": "MIT", - "engines": { - "node": ">=8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, "engines": { - "node": ">= 0.4" + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "license": "ISC", + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -6580,1448 +8079,1712 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "license": "MIT", "dependencies": { - "possible-typed-array-names": "^1.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">= 0.4" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/aws-cdk-lib": { - "version": "2.248.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.248.0.tgz", - "integrity": "sha512-PGQycx/OdyX+t0o6QUFI1KJAOLoyIVj2WwrN0syrwCi8lYxW2KzldZsW0X+/UN/ALNQwcjSr927ImTpuDOh+bg==", - "bundleDependencies": [ - "@balena/dockerignore", - "@aws-cdk/cloud-assembly-api", - "case", - "fs-extra", - "ignore", - "jsonschema", - "minimatch", - "punycode", - "semver", - "table", - "yaml", - "mime-types" - ], - "dev": true, - "license": "Apache-2.0", + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", "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", - "@balena/dockerignore": "^1.0.2", - "case": "1.6.3", - "fs-extra": "^11.3.3", - "ignore": "^5.3.2", - "jsonschema": "^1.5.0", - "mime-types": "^2.1.35", - "minimatch": "^10.2.3", - "punycode": "^2.3.1", - "semver": "^7.7.4", - "table": "^6.9.0", - "yaml": "1.10.3" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">= 20.0.0" + "node": ">=20" }, - "peerDependencies": { - "constructs": "^10.5.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.0", - "bundleDependencies": [ - "jsonschema", - "semver" - ], - "dev": true, - "inBundle": true, - "license": "Apache-2.0", + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", "dependencies": { - "jsonschema": "~1.4.1", - "semver": "^7.7.4" + "convert-to-spaces": "^2.0.1" }, "engines": { - "node": ">= 18.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" }, - "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.0.0" + "engines": { + "node": ">=7.0.0" } }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { - "version": "1.4.1", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true, - "inBundle": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": "*" + "node": ">=20" } }, - "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { - "version": "7.7.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">= 14" } }, - "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { - "version": "1.0.2", + "node_modules/constructs": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", + "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", "dev": true, - "inBundle": true, "license": "Apache-2.0" }, - "node_modules/aws-cdk-lib/node_modules/ajv": { - "version": "8.18.0", + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "dev": true, - "inBundle": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "engines": { + "node": ">=18" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/aws-cdk-lib/node_modules/ansi-regex": { - "version": "5.0.1", + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, - "inBundle": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/aws-cdk-lib/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "inBundle": true, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/aws-cdk-lib/node_modules/astral-regex": { - "version": "2.0.0", + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, - "inBundle": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/aws-cdk-lib/node_modules/balanced-match": { - "version": "4.0.4", + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "dev": true, - "inBundle": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">=6.6.0" } }, - "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "5.0.5", + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "dev": true, - "inBundle": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "object-assign": "^4", + "vary": "^1" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/aws-cdk-lib/node_modules/case": { - "version": "1.6.3", - "dev": true, - "inBundle": true, - "license": "(MIT OR GPL-3.0-or-later)", + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, "engines": { - "node": ">= 0.8.0" + "node": ">= 14" } }, - "node_modules/aws-cdk-lib/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "inBundle": true, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=7.0.0" + "node": ">= 8" } }, - "node_modules/aws-cdk-lib/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { - "version": "3.1.3", - "dev": true, - "inBundle": true, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/aws-cdk-lib/node_modules/fast-uri": { - "version": "3.1.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "inBundle": true, - "license": "BSD-3-Clause" - }, - "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.3.3", - "dev": true, - "inBundle": true, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" }, "engines": { - "node": ">=14.14" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-cdk-lib/node_modules/graceful-fs": { - "version": "4.2.11", - "dev": true, - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "inBundle": true, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, "engines": { - "node": ">= 4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "inBundle": true, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.2.0", - "dev": true, - "inBundle": true, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "universalify": "^2.0.0" + "ms": "^2.1.3" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.5.0", - "dev": true, - "inBundle": true, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", - "engines": { - "node": "*" + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { - "version": "4.4.2", - "dev": true, - "inBundle": true, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "license": "MIT" }, - "node_modules/aws-cdk-lib/node_modules/mime-db": { - "version": "1.52.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-types": { - "version": "2.1.35", - "dev": true, - "inBundle": true, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "10.2.5", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.1", + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "dev": true, - "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/aws-cdk-lib/node_modules/require-from-string": { - "version": "2.0.2", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", "engines": { - "node": ">=0.10.0" + "node": ">=0.3.1" } }, - "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.7.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" }, "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, - "node_modules/aws-cdk-lib/node_modules/slice-ansi": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, + "node_modules/dotenv": { + "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": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://dotenvx.com" } }, - "node_modules/aws-cdk-lib/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "inBundle": true, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/aws-cdk-lib/node_modules/strip-ansi": { - "version": "6.0.1", + "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", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", "dev": true, - "inBundle": true, - "license": "MIT", + "license": "Artistic-2.0", "dependencies": { - "ansi-regex": "^5.0.1" + "version-range": "^4.15.0" }, "engines": { - "node": ">=8" + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" } }, - "node_modules/aws-cdk-lib/node_modules/table": { - "version": "6.9.0", + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "dev": true, - "inBundle": true, - "license": "BSD-3-Clause", - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=10.0.0" - } + "license": "MIT" }, - "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.1", + "node_modules/electron-to-chromium": { + "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": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, - "inBundle": true, "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 0.8" } }, - "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.3", - "dev": true, - "inBundle": true, - "license": "ISC", + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", "engines": { - "node": ">= 6" - } - }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" + "node": ">=18" }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/es-abstract": { + "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", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" } }, - "node_modules/bare-fs": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", - "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } + "node": ">= 0.4" } }, - "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==", - "license": "Apache-2.0", + "node_modules/es-iterator-helpers": { + "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.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, "engines": { - "bare": ">=1.14.0" + "node": ">= 0.4" } }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" }, - "node_modules/bare-stream": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.12.0.tgz", - "integrity": "sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g==", - "license": "Apache-2.0", + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" + "es-errors": "^1.3.0" }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } + "engines": { + "node": ">= 0.4" } }, - "node_modules/bare-url": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", - "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", - "license": "Apache-2.0", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", "dependencies": { - "bare-path": "^3.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", - "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" }, "engines": { - "node": ">=6.0.0" + "node": ">= 0.4" } }, - "node_modules/binaryextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", - "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", - "dev": true, - "license": "Artistic-2.0", + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", "dependencies": { - "editions": "^6.21.0" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" }, "engines": { - "node": ">=4" + "node": ">= 0.4" }, "funding": { - "url": "https://bevry.me/fund" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "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", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { "node": ">=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "optionalDependencies": { + "@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/boundary": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", - "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", - "dev": true, - "license": "BSD-2-Clause" + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "devOptional": true, "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" + "bin": { + "eslint-config-prettier": "bin/cli.js" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "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==", + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "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" - }, - "bin": { - "browserslist": "cli.js" + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } } }, - "node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "node_modules/eslint-import-resolver-node": { + "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": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", - "engines": { - "node": ">=8.0.0" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", + "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, "engines": { - "node": ">= 0.8" + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } } }, - "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==", - "dev": true, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" + "debug": "^3.2.7" }, "engines": { - "node": ">= 0.4" + "node": ">=4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "ms": "^2.1.1" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" }, "engines": { - "node": ">= 0.4" + "node": ">=4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/cdk-from-cfn": { - "version": "0.293.0", - "resolved": "https://registry.npmjs.org/cdk-from-cfn/-/cdk-from-cfn-0.293.0.tgz", - "integrity": "sha512-ZanxDuUHG3JQwfeZE5aehPmn4Jt5SIxFeVZ8JjxHO0sn5mE/A6oS5Zt9hcaH7yXuDnMkPY5iD/G3wclddeTmcA==", - "license": "MIT OR Apache-2.0" + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, "engines": { - "node": ">=18" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">=4" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/eslint-plugin-react-hooks": { + "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": { - "readdirp": "^4.0.1" + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">=18" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "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/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "eslint": "^9 || ^10" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "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": { - "restore-cursor": "^4.0.0" + "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": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "license": "MIT", + "node_modules/eslint-plugin-security": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.0.tgz", + "integrity": "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" + "safe-regex": "^2.1.1" }, "engines": { - "node": ">=20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "node_modules/eslint-plugin-testing-library": { + "version": "7.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-7.16.2.tgz", + "integrity": "sha512-8gleGnQXK2ZA3hHwjCwpYTZvM+9VsrJ+/9kDI8CjqAQGAdMQOdn/rJNu7ZySENuiWlGKQWyZJ4ZjEg2zamaRHw==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "@typescript-eslint/scope-manager": "^8.56.0", + "@typescript-eslint/utils": "^8.56.0" }, "engines": { - "node": ">=20" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "license": "BSD-2-Clause", "dependencies": { - "convert-to-spaces": "^2.0.1" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": ">=7.0.0" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", "engines": { - "node": ">=20" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { - "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, - "engines": { - "node": ">= 14" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/constructs": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", - "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" + "url": "https://opencollective.com/eslint" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "license": "MIT", "engines": { - "node": ">=6.6.0" + "node": ">= 4" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">= 0.10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/eslint" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, "engines": { - "node": ">=0.8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", - "license": "MIT", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "license": "BSD-3-Clause", "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^4.0.0" + "estraverse": "^5.1.0" }, "engines": { - "node": ">= 14" + "node": ">=0.10" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 8" + "node": ">=4.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" + "node": ">= 0.6" } }, - "node_modules/data-view-byte-offset": { + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "eventsource-parser": "^3.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18.0.0" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18.0.0" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.4" + "node": ">= 18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" + "ip-address": "10.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">= 16" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, "engines": { - "node": ">= 0.8" + "node": ">=8.6.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "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==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "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==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" } }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.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==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", "dependencies": { - "esutils": "^2.0.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.2.0" }, - "engines": { - "node": ">=0.10.0" + "bin": { + "fxparser": "src/cli/cli.js" } }, - "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", - "license": "BSD-2-Clause", + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=12.0.0" }, - "funding": { - "url": "https://dotenvx.com" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "flat-cache": "^4.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=16.0.0" } }, - "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", - "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", - "dev": true, - "license": "Artistic-2.0", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", "dependencies": { - "version-range": "^4.15.0" + "to-regex-range": "^5.0.1" }, "engines": { - "ecmascript": ">= es5", - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" + "node": ">=8" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.334", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", - "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", - "dev": true, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -8030,114 +9793,92 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", + "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": ">= 0.4" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">= 0.6" } }, - "node_modules/es-iterator-helpers": { - "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==", + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0", - "safe-array-concat": "^1.1.3" - }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8" } }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=14.14" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -8146,952 +9887,844 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", - "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "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, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "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" + "node": ">= 0.4" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=6.9.0" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, - "peerDependencies": { - "eslint": ">=7.0.0" + "engines": { + "node": ">= 0.4" } }, - "node_modules/eslint-import-context": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", - "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", - "dev": true, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "license": "MIT", "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.2.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" - }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/get-tsconfig": { + "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": { - "debug": "^3.2.7", - "is-core-module": "^2.16.1", - "resolve": "^2.0.0-next.6" + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", + "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", "dependencies": { - "ms": "^2.1.1" + "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" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", - "dev": true, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "license": "ISC", "dependencies": { - "debug": "^4.4.1", - "eslint-import-context": "^0.1.8", - "get-tsconfig": "^4.10.1", - "is-bun-module": "^2.0.0", - "stable-hash-x": "^0.2.0", - "tinyglobby": "^0.2.14", - "unrs-resolver": "^1.7.11" + "is-glob": "^4.0.3" }, "engines": { - "node": "^16.17.0 || >=18.6.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } + "node": ">=10.13.0" } }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, "engines": { - "node": ">=4" + "node": ">=18" }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "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": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" + "@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": ">=4" + "node": ">=20" }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" }, "engines": { - "node": ">=4" + "node": ">=0.4.7" }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "node_modules/eslint-plugin-react-hooks": { - "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==", - "dev": true, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", - "dev": true, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" + "engines": { + "node": ">=8" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-security": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.0.tgz", - "integrity": "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", "dependencies": { - "safe-regex": "^2.1.1" + "dunder-proto": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "hermes-estree": "0.25.1" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "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==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/hosted-git-info": { + "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": "BSD-2-Clause", + "license": "ISC", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/hosted-git-info/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==", "dev": true, - "license": "Apache-2.0", + "license": "BlueOak-1.0.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "20 || >=22" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.2.0" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { - "node": ">=4.0" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "bin": { + "husky": "bin.js" + }, "engines": { - "node": ">=4.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 4" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, "engines": { "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "license": "MIT", "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" + "node": ">=0.8.19" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, "engines": { - "node": ">=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, + "node_modules/ink": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", + "integrity": "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==", "license": "MIT", "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "@alcalzone/ansi-tokenize": "^0.2.4", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^5.1.1", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^8.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.1", + "terminal-size": "^4.0.1", + "type-fest": "^5.4.1", + "widest-line": "^6.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" }, "engines": { - "node": ">= 18" + "node": ">=20" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } } }, - "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==", - "dev": true, + "node_modules/ink-link": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-link/-/ink-link-5.0.0.tgz", + "integrity": "sha512-TFDXc/0mwUW7LMjsr0/LeLxPVV5BnHDuDQff9RCgP4rb3R+V/4dIwGBZbCevcJZtQnVcW+Iz1LUrUbpq+UDwYA==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "terminal-link": "^5.0.0" }, "engines": { - "node": ">= 16" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/express-rate-limit" + "url": "https://github.com/sponsors/sindresorhus" }, "peerDependencies": { - "express": ">= 4.11" + "ink": ">=6" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "cli-spinners": "^2.7.0" }, "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" + "node": ">=14.16" }, - "engines": { - "node": ">= 6" + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", "dev": true, - "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==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true } - ], - "license": "BSD-3-Clause" + } }, - "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/ink/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", - "dependencies": { - "path-expression-matcher": "^1.1.3" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "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" - } - ], + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.2.0" + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/ink/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, - "bin": { - "fxparser": "src/cli/cli.js" + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", + "node_modules/ink/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", "dependencies": { - "reusify": "^1.0.4" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "node_modules/ink/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">=18" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">= 0.4" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "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==", + "dev": true, "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">= 12" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, "engines": { - "node": ">= 18.0.0" - }, + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": ">=16" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -9100,95 +10733,99 @@ "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", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" + "has-bigints": "^1.0.2" }, "engines": { - "node": ">=14" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fresh": { + "node_modules/is-bun-module": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "semver": "^7.7.1" } }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "hasown": "^2.0.2" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -9197,41 +10834,48 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { "node": ">=18" }, @@ -9239,23 +10883,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -9264,141 +10902,146 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "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", - "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" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "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": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "license": "MIT", "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9407,76 +11050,73 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "license": "MIT", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=0.4.7" + "node": ">= 0.4" }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.0" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -9485,11 +11125,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9498,14 +11137,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -9514,614 +11152,767 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" } }, - "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=16.9.0" + "node": ">=10" } }, - "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==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "ISC", + "license": "BSD-3-Clause", "dependencies": { - "lru-cache": "^10.0.1" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=8" } }, - "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==", + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", "dev": true, - "license": "ISC" + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "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", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "dev": true, "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" + "argparse": "^2.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { - "husky": "bin.js" + "jsesc": "bin/jsesc" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": ">=6" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=6" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4.0" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", - "engines": { - "node": ">=0.8.19" + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "readable-stream": "^2.0.5" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.6.3" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" }, - "node_modules/ink": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", - "integrity": "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==", + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { - "@alcalzone/ansi-tokenize": "^0.2.4", - "ansi-escapes": "^7.3.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.6.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^5.1.1", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.39.10", - "indent-string": "^5.0.0", - "is-in-ci": "^2.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.33.0", - "scheduler": "^0.27.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^8.0.0", - "stack-utils": "^2.0.6", - "string-width": "^8.1.1", - "terminal-size": "^4.0.1", - "type-fest": "^5.4.1", - "widest-line": "^6.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@types/react": ">=19.0.0", - "react": ">=19.0.0", - "react-devtools-core": ">=6.1.2" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } + "safe-buffer": "~5.1.0" } }, - "node_modules/ink-link": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ink-link/-/ink-link-5.0.0.tgz", - "integrity": "sha512-TFDXc/0mwUW7LMjsr0/LeLxPVV5BnHDuDQff9RCgP4rb3R+V/4dIwGBZbCevcJZtQnVcW+Iz1LUrUbpq+UDwYA==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "license": "MIT", "dependencies": { - "terminal-link": "^5.0.0" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "peerDependencies": { - "ink": ">=6" + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/ink-spinner": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", - "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", - "license": "MIT", - "dependencies": { - "cli-spinners": "^2.7.0" - }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14.16" + "node": ">= 12.0.0" }, - "peerDependencies": { - "ink": ">=4.0.0", - "react": ">=18.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ink-testing-library": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", - "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0" + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ink/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", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ink/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ink/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/ink/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/ink/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ink/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ink/node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", "dev": true, "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">= 0.4" + "node": ">=20.17" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/lint-staged" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { - "has-bigints": "^1.0.2" + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=20.0.0" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/listr2/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==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } + "license": "MIT" }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" + "p-locate": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/log-update/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==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "mimic-function": "^5.0.0" }, "engines": { "node": ">=18" @@ -10130,1189 +11921,1545 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/is-in-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", - "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", - "bin": { - "is-in-ci": "cli.js" + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "engines": { - "node": ">=0.12.0" + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz", + "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "semver": "^7.5.3" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, "engines": { "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { + "node_modules/mdast-util-gfm-task-list-item": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": ">=10" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/istanbul-reports": { + "node_modules/mdast-util-mdx-jsx": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/istextorbinary": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", - "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", - "dev": true, - "license": "Artistic-2.0", + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", "dependencies": { - "binaryextensions": "^6.11.0", - "editions": "^6.21.0", - "textextensions": "^6.11.0" - }, - "engines": { - "node": ">=4" + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://bevry.me/fund" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "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", + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/javascript-natural-sort": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", - "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "dev": true, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/js-tokens": { + "node_modules/mdast-util-to-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "@types/mdast": "^4.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">= 0.8" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, - "engines": { - "node": ">=6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", "license": "MIT", "dependencies": { - "universalify": "^2.0.0" + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, - "engines": { - "node": ">=4.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", "license": "MIT", "dependencies": { - "readable-stream": "^2.0.5" + "micromark-util-types": "^2.0.0" }, - "engines": { - "node": ">= 0.6.3" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/lazystream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "license": "MIT" }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=8.6" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=8.6" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=4.0.0" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">= 0.6" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/express" } }, - "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, "license": "MIT", - "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", - "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, "engines": { - "node": ">=20.17" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/lint-staged" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=20.0.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/listr2/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==", - "dev": true, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", - "engines": { - "node": ">=12" - }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "bin": { + "napi-postinstall": "lib/cli.js" }, "engines": { - "node": ">=18" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "dev": true, - "license": "MIT" + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "node_modules/node-releases": { + "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/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "node_modules/normalize-package-data": { + "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": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/log-update/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==", - "dev": true, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/log-update/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "license": "MIT", "dependencies": { - "mimic-function": "^5.0.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/log-update/node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "license": "MIT", "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "ee-first": "1.1.1" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": ">= 0.8" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { - "yallist": "^3.0.2" + "wrappy": "1" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "license": "MIT", "dependencies": { - "semver": "^7.5.3" + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, "engines": { - "node": ">= 0.8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", "engines": { @@ -11322,884 +13469,965 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } + "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/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "callsites": "^3.0.0" }, "engines": { - "node": ">=8.6" + "node": ">=6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", "license": "MIT", - "engines": { - "node": ">=8.6" + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "parse-statements": "1.0.11" } }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "node_modules/parse-json/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", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=18" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 0.8" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=8" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/ai" + "url": "https://github.com/sponsors/NaturalIntelligence" } ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=14.0.0" } }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "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==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": ">=16 || 14 >=14.18" }, "funding": { - "url": "https://opencollective.com/napi-postinstall" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "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" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "node_modules/path-to-regexp": { + "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", - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0" + "engines": { + "node": ">=16.20.0" } }, - "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==", - "dev": true, - "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==", + "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": "BSD-2-Clause", + "license": "Apache-2.0", "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", + "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": ">=0.10.0" + "node": ">=18" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "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": ">=0.10.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10 || ^12 || >=14" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8.0" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">= 0.4" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" + "fast-diff": "^1.1.2" }, "engines": { - "node": ">= 0.4" + "node": ">=6.0.0" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6.0" } }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "wrappy": "1" + "@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": ">=12.0.0" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.10" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, "engines": { - "node": ">= 0.8.0" + "node": ">=6" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "node_modules/qs": { + "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": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" + "side-channel": "^1.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=0.6" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.10" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" } }, - "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/react": { + "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" + } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { - "callsites": "^3.0.0" + "scheduler": "^0.27.0" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "react": "^19.2.5" } }, - "node_modules/parse-imports-exports": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", - "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", - "dev": true, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "license": "MIT", "dependencies": { - "parse-statements": "1.0.11" + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" } }, - "node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" + "scheduler": "^0.27.0" }, "engines": { - "node": ">=18" + "node": ">=0.10.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": "^19.2.0" } }, - "node_modules/parse-json/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "node_modules/read-pkg": { + "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 OR CC0-1.0)", + "license": "MIT", + "dependencies": { + "@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" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-statements": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", - "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", - "dev": true, - "license": "MIT" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, + "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", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, "engines": { - "node": ">= 0.8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "node_modules/readable-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/path-expression-matcher": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz", - "integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, "engines": { - "node": ">=14.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "bin": { + "regexp-tree": "bin/regexp-tree" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "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==", - "license": "BlueOak-1.0.0", + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "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" - }, - "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==", - "dev": true, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/unified" } }, - "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, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", "license": "MIT", - "engines": { - "node": ">=16.20.0" + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.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/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=4" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8.0" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" }, "engines": { - "node": ">=14" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "license": "MIT", "engines": { - "node": ">= 0.6.0" + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } + "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/rolldown": { + "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": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">= 0.10" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@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/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, "engines": { - "node": ">=6" + "node": ">= 18" } }, - "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", "dependencies": { - "side-channel": "^1.1.0" + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" }, "engines": { - "node": ">=0.6" + "node": ">=0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -12216,199 +14444,282 @@ ], "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" + "regexp-tree": "~0.1.1" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" }, "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rc-config-loader": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", - "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "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==", "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", "debug": "^4.4.3", - "js-yaml": "^4.1.1", - "json5": "^2.2.3", - "require-from-string": "^2.0.2" + "globby": "^16.2.0", + "read-pkg": "^10.1.0" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=22.0.0" } }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, - "node_modules/react-reconciler": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", - "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" }, - "peerDependencies": { - "react": "^19.2.0" + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" } }, - "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==", + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "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" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "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)", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "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, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "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/side-channel-list": { + "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": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/readable-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">= 14.18.0" + "node": ">= 0.4" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -12417,489 +14728,370 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "MIT", - "bin": { - "regexp-tree": "bin/regexp-tree" - } + "license": "ISC" }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", "engines": { - "node": ">= 0.4" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "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": ">=0.10.0" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", - "dev": true, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "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" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "node_modules/slice-ansi/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": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "license": "MIT", "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" } }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">= 10.x" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "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==", + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", "dev": true, "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, "engines": { - "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" + "node": ">=12.0.0" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" + "escape-string-regexp": "^2.0.0" }, "engines": { - "node": ">= 18" + "node": ">=10" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" + "engines": { + "node": ">=8" } }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/std-env": { + "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" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "isarray": "^2.0.5" + "internal-slot": "^1.1.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", - "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", - "dev": true, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", "license": "MIT", "dependencies": { - "regexp-tree": "~0.1.1" + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" } }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, + "node_modules/stream-browserify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 6" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/secretlint": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-11.6.0.tgz", - "integrity": "sha512-nExts3zhuSF4khODkxxtchAAQVa1z7ID4GydlDKJKTsg5bEI4akwO4GdZXCRnQ0LOEcEC8GMqAy+rg7uiU9Mlw==", - "dev": true, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", "license": "MIT", "dependencies": { - "@secretlint/config-creator": "11.6.0", - "@secretlint/formatter": "11.6.0", - "@secretlint/node": "11.6.0", - "@secretlint/profiler": "11.6.0", - "@secretlint/resolver": "11.6.0", - "debug": "^4.4.3", - "globby": "^14.1.0", - "read-pkg": "^9.0.1" - }, - "bin": { - "secretlint": "bin/secretlint.js" - }, - "engines": { - "node": ">=20.0.0" + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" } }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=0.6.19" } }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, + "node_modules/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": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=8" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, + "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": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, + "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", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, + "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", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "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": { - "shebang-regex": "^3.0.0" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, - "node_modules/shebang-regex": { + "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", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "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/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, + "node_modules/string-width/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": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "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==", - "dev": true, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "license": "MIT", "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -12908,17 +15100,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "license": "MIT", "dependencies": { + "call-bind": "^1.0.8", "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -12927,18 +15131,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "license": "MIT", "dependencies": { + "call-bind": "^1.0.8", "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -12947,283 +15149,231 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "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, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=14.16" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/slice-ansi/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==", + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, + "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": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">= 10.x" + "node": ">=8" } }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", - "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", - "dev": true, + "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": ">=12.0.0" + "node": ">=8" } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, "engines": { - "node": ">=10" + "node": ">=4" } }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { + "node_modules/structured-source": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "style-to-object": "1.0.14" } }, - "node_modules/stream-browserify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", "license": "MIT", "dependencies": { - "inherits": "~2.0.4", - "readable-stream": "^3.5.0" + "inline-style-parser": "0.2.7" } }, - "node_modules/stream-browserify/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=8" } }, - "node_modules/streamx": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", - "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "node_modules/supports-hyperlinks": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", + "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", "license": "MIT", "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "license": "MIT", "engines": { - "node": ">=0.6.19" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/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==", + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "@pkgr/core": "^0.2.9" }, "engines": { - "node": ">=8" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" } }, - "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", + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=10.0.0" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { + "node_modules/table/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==", @@ -13232,7 +15382,7 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "node_modules/table/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==", @@ -13241,7 +15391,24 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/table/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==", @@ -13253,573 +15420,734 @@ "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", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string-width/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==", + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, - "node_modules/string-width/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==", + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "streamx": "^2.12.5" } }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, + "node_modules/terminal-link": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-5.0.0.tgz", + "integrity": "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^4.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" + "b4a": "^1.6.4" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "editions": "^6.21.0" }, "engines": { - "node": ">= 0.4" + "node": ">=4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://bevry.me/fund" } }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "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": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">= 0.4" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "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": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "is-number": "^7.0.0" }, "engines": { - "node": ">= 0.4" + "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" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "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": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" } }, - "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==", + "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": { - "ansi-regex": "^5.0.1" + "minimist": "^1.2.0" }, - "engines": { - "node": ">=8" + "bin": { + "json5": "lib/cli.js" } }, - "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==", + "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": ">=8" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "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": ">=4" + "node": ">=18" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "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": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } + "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" ], - "license": "MIT" - }, - "node_modules/structured-source": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", - "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boundary": "^2.0.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/supports-hyperlinks": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", - "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", + "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", - "dependencies": { - "has-flag": "^5.0.1", - "supports-color": "^10.2.2" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + "node": ">=18" } }, - "node_modules/supports-hyperlinks/node_modules/has-flag": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", - "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "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": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "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" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "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": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/table": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", - "license": "BSD-3-Clause", - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, + "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": ">=10.0.0" + "node": ">=18" } }, - "node_modules/table/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==", + "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": ">=8" + "node": ">=18" } }, - "node_modules/table/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==", + "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": ">=8" + "node": ">=18" } }, - "node_modules/table/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "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", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/table/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==", + "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", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "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": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/tar-stream": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", - "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "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", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "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", - "dependencies": { - "streamx": "^2.12.5" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/terminal-link": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-5.0.0.tgz", - "integrity": "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==", + "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", - "dependencies": { - "ansi-escapes": "^7.0.0", - "supports-hyperlinks": "^4.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/terminal-size": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", - "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/textextensions": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", - "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "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": "Artistic-2.0", - "dependencies": { - "editions": "^6.21.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=4" - }, - "funding": { - "url": "https://bevry.me/fund" + "node": ">=18" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "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/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": ">=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/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": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "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/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": ">=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/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", - "dependencies": { - "is-number": "^7.0.0" - }, + "optional": true, + "os": [ + "openharmony" + ], "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/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", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=0.6" + "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -13862,7 +16190,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -13877,7 +16204,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -13897,7 +16223,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -13919,7 +16244,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -13940,7 +16264,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13951,16 +16274,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", - "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "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.1", - "@typescript-eslint/parser": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1" + "@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" @@ -13971,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": { @@ -13991,7 +16314,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -14010,22 +16332,108 @@ "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": { - "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" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -14084,7 +16492,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -14115,7 +16522,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -14174,18 +16580,46 @@ "url": "https://bevry.me/fund" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "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" @@ -14253,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", @@ -14293,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" @@ -14361,7 +16795,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", @@ -14381,7 +16814,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -14409,7 +16841,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, "license": "MIT", "dependencies": { "is-map": "^2.0.3", @@ -14428,7 +16859,6 @@ "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -14498,7 +16928,6 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14619,7 +17048,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, "license": "ISC" }, "node_modules/yaml": { @@ -14679,20 +17107,19 @@ } }, "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -14700,6 +17127,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 31c08ad39..e63de389e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@aws/agentcore-dev", - "version": "0.8.0-dev", + "name": "@aws/agentcore", + "version": "0.11.0", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { @@ -30,7 +30,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "agentcore-dev": "dist/cli/index.mjs" + "agentcore": "dist/cli/index.mjs" }, "exports": { ".": { @@ -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": { @@ -86,7 +87,13 @@ "@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", "@commander-js/extra-typings": "^14.0.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", @@ -108,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", @@ -119,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", @@ -133,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 3ccb8002e..15877fa42 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -74,7 +74,7 @@ "anyOf": [ { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, { "type": "string", @@ -142,7 +142,7 @@ }, "protocol": { "type": "string", - "enum": ["HTTP", "MCP", "A2A"] + "enum": ["HTTP", "MCP", "A2A", "AGUI"] }, "requestHeaderAllowlist": { "maxItems": 20, @@ -280,6 +280,27 @@ } }, "additionalProperties": false + }, + "filesystemConfigurations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sessionStorage": { + "type": "object", + "properties": { + "mountPath": { + "type": "string", + "pattern": "^\\/mnt\\/[^/]+$" + } + }, + "required": ["mountPath"], + "additionalProperties": false + } + }, + "required": ["sessionStorage"], + "additionalProperties": false + } } }, "required": ["name", "build", "entrypoint", "codeLocation"], @@ -300,7 +321,7 @@ }, "eventExpiryDuration": { "type": "integer", - "minimum": 7, + "minimum": 3, "maximum": 365 }, "strategies": { @@ -774,7 +795,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "timeout": { "type": "integer", @@ -839,7 +860,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "name": { "type": "string", @@ -1269,7 +1290,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "name": { "type": "string", @@ -1427,7 +1448,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "timeout": { "type": "integer", @@ -1492,7 +1513,7 @@ }, "pythonVersion": { "type": "string", - "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + "enum": ["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13", "PYTHON_3_14"] }, "name": { "type": "string", diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts index c00d0b209..c65fe603c 100644 --- a/scripts/bump-version.ts +++ b/scripts/bump-version.ts @@ -7,17 +7,12 @@ * npx tsx scripts/bump-version.ts [options] * * Arguments: - * bump_type: major | minor | patch | prerelease | preview | preview-major + * bump_type: major | minor | patch | prerelease * * Options: * --changelog Custom changelog entry * --prerelease-tag Prerelease identifier (default: beta) * --dry-run Show what would be done without making changes - * - * Preview bumps (internal format): - * - 0.3.0 -> 0.3.0-preview.1.0 - * - 0.3.0-preview.1.0 -> 0.3.0-preview.1.1 (preview) - * - 0.3.0-preview.1.0 -> 0.3.0-preview.2.0 (preview-major) */ import { execSync } from 'child_process'; import { existsSync, readFileSync, writeFileSync } from 'fs'; @@ -26,7 +21,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; // Types // =========================== -type BumpType = 'major' | 'minor' | 'patch' | 'prerelease' | 'preview' | 'preview-major'; +type BumpType = 'major' | 'minor' | 'patch' | 'prerelease'; interface ParsedVersion { major: number; @@ -34,9 +29,6 @@ interface ParsedVersion { patch: number; prerelease?: string; prereleaseNum?: number; - // For preview format: X.Y.Z-previewN.M - previewMajor?: number; - previewMinor?: number; } interface PackageJson { @@ -58,18 +50,6 @@ interface PackageLockJson { // =========================== function parseVersion(version: string): ParsedVersion { - // First try to match preview format: X.Y.Z-preview.N.M (e.g., 0.3.0-preview.1.0) - const previewMatch = /^(\d+)\.(\d+)\.(\d+)-preview\.(\d+)\.(\d+)$/.exec(version); - if (previewMatch) { - return { - major: parseInt(previewMatch[1]!, 10), - minor: parseInt(previewMatch[2]!, 10), - patch: parseInt(previewMatch[3]!, 10), - previewMajor: parseInt(previewMatch[4]!, 10), - previewMinor: parseInt(previewMatch[5]!, 10), - }; - } - // Match standard versions like: 1.2.3, 1.2.3-beta.1, 1.2.3-rc.0 const match = /^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z]+)\.(\d+))?$/.exec(version); @@ -89,11 +69,6 @@ function parseVersion(version: string): ParsedVersion { function formatVersion(parsed: ParsedVersion): string { const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`; - // Handle preview format: X.Y.Z-preview.N.M - if (parsed.previewMajor !== undefined && parsed.previewMinor !== undefined) { - return `${base}-preview.${parsed.previewMajor}.${parsed.previewMinor}`; - } - // Handle standard prerelease format: X.Y.Z-tag.N if (parsed.prerelease !== undefined && parsed.prereleaseNum !== undefined) { return `${base}-${parsed.prerelease}.${parsed.prereleaseNum}`; @@ -121,8 +96,8 @@ function bumpVersion(current: string, bumpType: BumpType, prereleaseTag = 'beta' }); case 'patch': - // If currently a prerelease or preview, just remove the suffix - if (parsed.prerelease || parsed.previewMajor !== undefined) { + // If currently a prerelease, just remove the suffix + if (parsed.prerelease) { return formatVersion({ major: parsed.major, minor: parsed.minor, @@ -155,47 +130,6 @@ function bumpVersion(current: string, bumpType: BumpType, prereleaseTag = 'beta' prereleaseNum: 0, }); - case 'preview': - // Handle preview format: X.Y.Z-previewN.M - // If already a preview, increment the minor preview number - if (parsed.previewMajor !== undefined && parsed.previewMinor !== undefined) { - return formatVersion({ - major: parsed.major, - minor: parsed.minor, - patch: parsed.patch, - previewMajor: parsed.previewMajor, - previewMinor: parsed.previewMinor + 1, - }); - } - // Otherwise, start at preview.1.0 - return formatVersion({ - major: parsed.major, - minor: parsed.minor, - patch: parsed.patch, - previewMajor: 1, - previewMinor: 0, - }); - - case 'preview-major': - // Increment the major preview number and reset minor to 0 - if (parsed.previewMajor !== undefined && parsed.previewMinor !== undefined) { - return formatVersion({ - major: parsed.major, - minor: parsed.minor, - patch: parsed.patch, - previewMajor: parsed.previewMajor + 1, - previewMinor: 0, - }); - } - // Otherwise, start at preview.1.0 - return formatVersion({ - major: parsed.major, - minor: parsed.minor, - patch: parsed.patch, - previewMajor: 1, - previewMinor: 0, - }); - default: { const exhaustiveCheck: never = bumpType; throw new Error(`Unknown bump type: ${exhaustiveCheck as string}`); @@ -392,27 +326,20 @@ function parseArgs(): { bumpType: BumpType; changelog?: string; prereleaseTag: s Usage: npx tsx scripts/bump-version.ts [options] Arguments: - bump_type: major | minor | patch | prerelease | preview | preview-major + bump_type: major | minor | patch | prerelease Options: --changelog Custom changelog entry --prerelease-tag Prerelease identifier (default: beta) --dry-run Show what would be done without making changes --help, -h Show this help message - -Preview bumps: - - 0.3.0 -> 0.3.0-preview.1.0 - - 0.3.0-preview.1.0 -> 0.3.0-preview.1.1 (preview) - - 0.3.0-preview.1.0 -> 0.3.0-preview.2.0 (preview-major) `); process.exit(0); } const bumpType = args[0] as BumpType; - if (!['major', 'minor', 'patch', 'prerelease', 'preview', 'preview-major'].includes(bumpType)) { - console.error( - `Error: Invalid bump type '${bumpType}'. Must be one of: major, minor, patch, prerelease, preview, preview-major` - ); + if (!['major', 'minor', 'patch', 'prerelease'].includes(bumpType)) { + console.error(`Error: Invalid bump type '${bumpType}'. Must be one of: major, minor, patch, prerelease`); process.exit(1); } diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index a58b3ea48..0be7bbb7d 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -7,6 +7,8 @@ const __dirname = path.dirname(__filename); const srcDir = path.join(__dirname, '..', 'src', 'assets'); const destDir = path.join(__dirname, '..', 'dist', 'assets'); +const inspectorSrcDir = path.join(__dirname, '..', 'node_modules', '@aws', 'agent-inspector', 'dist-assets'); +const inspectorDestDir = path.join(__dirname, '..', 'dist', 'agent-inspector'); /** * Recursively copy directory contents, excluding specified files at root level only @@ -44,6 +46,18 @@ try { console.log('Copying assets...'); copyDir(srcDir, destDir, ['AGENTS.md']); console.log('Assets copied successfully!'); + + // Copy @aws/agent-inspector built assets into dist/agent-inspector/ for bundled CLI + if (fs.existsSync(inspectorSrcDir)) { + console.log('Copying @aws/agent-inspector assets...'); + copyDir(inspectorSrcDir, inspectorDestDir); + console.log('@aws/agent-inspector assets copied successfully!'); + } else { + console.error( + 'Error: @aws/agent-inspector dist-assets/ not found. Run "npm install" to ensure the package is available.' + ); + process.exit(1); + } } catch (error) { console.error('Error copying assets:', error); process.exit(1); diff --git a/src/assets/AGENTS.md b/src/assets/AGENTS.md index 9c583f4d7..7ab8ab6d9 100644 --- a/src/assets/AGENTS.md +++ b/src/assets/AGENTS.md @@ -2,27 +2,47 @@ This directory stores: -- Template assets for agents written in different Languages, SDKs and having different configurations +- Template assets for agents written in different languages, SDKs, and configurations - Container templates (`container/python/`) with `Dockerfile` and `.dockerignore` for Container build agents +- Vended documentation (`README.md`, `agents/AGENTS.md`) copied into user projects at create time +- CDK project template (`cdk/`) using `@aws/agentcore-cdk` L3 constructs +- Evaluator templates (`evaluators/`) for code-based evaluators +- MCP tool templates (`mcp/`) for Lambda and AgentCoreRuntime compute ### Directory Layout ``` assets/ -├── python/ # Framework templates (one per SDK) -│ ├── strands/ -│ ├── langchain_langgraph/ -│ ├── googleadk/ -│ ├── openaiagents/ -│ └── autogen/ +├── README.md # Vended to project root as project README +├── AGENTS.md # This file — internal dev context +├── agents/ +│ └── AGENTS.md # Vended to project root for AI coding assistants +├── python/ # Framework templates (one per SDK per protocol) +│ ├── http/ # HTTP protocol agents +│ │ ├── strands/ +│ │ ├── langchain_langgraph/ +│ │ ├── googleadk/ +│ │ ├── openaiagents/ +│ │ └── autogen/ +│ ├── mcp/ # MCP protocol agents +│ │ └── standalone/ +│ └── a2a/ # A2A protocol agents +│ ├── strands/ +│ ├── langchain_langgraph/ +│ └── googleadk/ +├── typescript/ # TypeScript agent templates ├── container/ # Container build templates │ └── python/ │ ├── Dockerfile │ └── dockerignore.template -└── agents/ # AGENTS.md vended to user projects +├── cdk/ # CDK project template (@aws/agentcore-cdk) +├── evaluators/ # Code-based evaluator templates +└── mcp/ # MCP tool templates (Lambda + AgentCoreRuntime) + ├── python/ + └── python-lambda/ ``` -The rendering logic is rooted in the `AgentEnvSpec` and must ALWAYS respect the configuration in the Spec. +The rendering logic is rooted in the `AgentEnvSpec` and must ALWAYS respect the configuration in the spec. For Container builds, `BaseRenderer.render()` automatically copies the `container//` templates (Dockerfile, .dockerignore) into the agent directory when `buildType === 'Container'`. @@ -31,7 +51,10 @@ For Container builds, `BaseRenderer.render()` automatically copies the `containe - Always make sure the templates are as close to working code as possible - AVOID as much as possible using any conditionals within the templates +- Test template rendering with `agentcore add agent` for each framework/protocol combination ## How to use the assets in this directory -- These assets are rendered by the CLI's template renderer in `src/cli/templates/`. +- These assets are rendered by the CLI's template renderer in `src/cli/templates/` +- The `README.md` and `agents/AGENTS.md` are copied verbatim (no template rendering) during project creation +- The `.llm-context/` files are sourced from `src/schema/llm-compacted/` and written during init diff --git a/src/assets/README.md b/src/assets/README.md index f808b99f7..7064d6466 100644 --- a/src/assets/README.md +++ b/src/assets/README.md @@ -5,14 +5,19 @@ This project was created with the [AgentCore CLI](https://github.com/aws/agentco ## Project Structure ``` -. my-project/ +├── AGENTS.md # AI coding assistant context ├── agentcore/ -│ ├── .env.local # API keys (gitignored) -│ ├── agentcore.json # Resource specifications -│ ├── aws-targets.json # Deployment targets -│ └── cdk/ # CDK infrastructure -├── app/ # Application code +│ ├── agentcore.json # Project config (agents, memories, credentials, gateways, evaluators) +│ ├── aws-targets.json # Deployment targets (account + region) +│ ├── .env.local # Secrets — API keys (gitignored) +│ ├── .llm-context/ # TypeScript type definitions for AI assistants +│ │ ├── agentcore.ts # AgentCoreProjectSpec types +│ │ ├── aws-targets.ts # Deployment target types +│ │ └── mcp.ts # Gateway and MCP tool types +│ └── cdk/ # CDK infrastructure (@aws/agentcore-cdk) +├── app/ # Agent application code +└── evaluators/ # Custom evaluator code (if any) ``` ## Getting Started @@ -20,7 +25,9 @@ my-project/ ### Prerequisites - **Node.js** 20.x or later -- **uv** for Python agents ([install](https://docs.astral.sh/uv/getting-started/installation/)) +- **Python 3.10+** and **uv** for Python agents ([install uv](https://docs.astral.sh/uv/getting-started/installation/)) +- **AWS credentials** configured (`aws configure` or environment variables) +- **Docker** (only for Container build agents) ### Development @@ -38,42 +45,60 @@ Deploy to AWS: agentcore deploy ``` -Or use CDK directly: +## Commands -```bash -cd agentcore/cdk -npx cdk deploy -``` +| Command | Description | +| --- | --- | +| `agentcore create` | Create a new AgentCore project | +| `agentcore add` | Add resources (agent, memory, credential, gateway, evaluator, policy) | +| `agentcore remove` | Remove resources | +| `agentcore dev` | Run agent locally with hot-reload | +| `agentcore deploy` | Deploy to AWS via CDK | +| `agentcore status` | Show deployment status | +| `agentcore invoke` | Invoke agent (local or deployed) | +| `agentcore logs` | View agent logs | +| `agentcore traces` | View agent traces | +| `agentcore eval` | Run evaluations | +| `agentcore package` | Package agent artifacts | +| `agentcore validate` | Validate configuration | +| `agentcore pause` | Pause a deployed agent | +| `agentcore resume` | Resume a paused agent | +| `agentcore fetch` | Fetch remote resource definitions | +| `agentcore import` | Import existing resources | +| `agentcore update` | Check for CLI updates | ## Configuration -Edit the JSON files in `agentcore/` to configure your agents, memory, and credentials. See `agentcore/.llm-context/` for -type definitions and validation constraints. +Edit the JSON files in `agentcore/` to configure your project. See `agentcore/.llm-context/` for type definitions and validation constraints. -The project uses a **flat resource model** where agents, memories, and credentials are top-level arrays in -`agentcore.json`. +The project uses a **flat resource model** — agents, memories, credentials, gateways, evaluators, and policies are top-level arrays in `agentcore.json`. Resources are independent; agents discover memories and credentials at runtime via environment variables or SDK calls. -## Commands +## Resources -| Command | Description | -| -------------------- | ----------------------------------------------- | -| `agentcore create` | Create a new AgentCore project | -| `agentcore add` | Add resources (agent, memory, credential, target) | -| `agentcore remove` | Remove resources | -| `agentcore dev` | Run agent locally | -| `agentcore deploy` | Deploy to AWS | -| `agentcore status` | Show deployment status | -| `agentcore invoke` | Invoke agent (local or deployed) | -| `agentcore package` | Package agent artifacts | -| `agentcore validate` | Validate configuration | -| `agentcore update` | Check for CLI updates | +| Resource | Purpose | +| --- | --- | +| Agent (runtime) | HTTP, MCP, or A2A agent deployed to AgentCore Runtime | +| Memory | Persistent context storage with configurable strategies | +| Credential | API key or OAuth credential providers | +| Gateway | MCP gateway that routes tool calls to targets | +| Gateway Target | Tool implementation (Lambda, MCP server, OpenAPI, Smithy, API Gateway) | +| Evaluator | Custom LLM-as-a-Judge or code-based evaluation | +| Online Eval Config | Continuous evaluation pipeline for deployed agents | +| Policy | Cedar authorization policies for gateway tools | ### Agent Types -- **Template agents**: Created from framework templates (Strands, LangChain_LangGraph, CrewAI, GoogleADK, OpenAIAgents) +- **Template agents**: Created from framework templates (Strands, LangChain/LangGraph, GoogleADK, OpenAI Agents, Autogen) - **BYO agents**: Bring your own code with `agentcore add agent --type byo` +- **Import agents**: Import existing Bedrock agents with `agentcore import` + +### Build Types + +- **CodeZip**: Python source packaged as a zip and deployed directly to AgentCore Runtime +- **Container**: Docker image built via CodeBuild (ARM64), pushed to ECR, and deployed to AgentCore Runtime ## Documentation -- [AgentCore CLI Documentation](https://github.com/aws/agentcore-cli) +- [AgentCore CLI](https://github.com/aws/agentcore-cli) +- [AgentCore CDK Constructs](https://github.com/aws/agentcore-l3-cdk-constructs) - [Amazon Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 3c84e64f3..499d07e70 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -149,7 +149,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/cdk.json should match "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, "@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/core:checkSecretUsage": true, - "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk/core:target-partitions": ["aws", "aws-cn", "aws-us-gov"], "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, @@ -357,7 +357,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/package.json should m "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "0.1.0-alpha.17", + "@aws/agentcore-cdk": "^0.1.0-alpha.19", "aws-cdk-lib": "^2.248.0", "constructs": "^10.0.0" } @@ -476,6 +476,26 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/a2a/strands/base/pyproject.toml", "python/a2a/strands/capabilities/memory/__init__.py", "python/a2a/strands/capabilities/memory/session.py", + "python/agui/googleadk/base/README.md", + "python/agui/googleadk/base/gitignore.template", + "python/agui/googleadk/base/main.py", + "python/agui/googleadk/base/model/__init__.py", + "python/agui/googleadk/base/model/load.py", + "python/agui/googleadk/base/pyproject.toml", + "python/agui/langchain_langgraph/base/README.md", + "python/agui/langchain_langgraph/base/gitignore.template", + "python/agui/langchain_langgraph/base/main.py", + "python/agui/langchain_langgraph/base/model/__init__.py", + "python/agui/langchain_langgraph/base/model/load.py", + "python/agui/langchain_langgraph/base/pyproject.toml", + "python/agui/strands/base/README.md", + "python/agui/strands/base/gitignore.template", + "python/agui/strands/base/main.py", + "python/agui/strands/base/model/__init__.py", + "python/agui/strands/base/model/load.py", + "python/agui/strands/base/pyproject.toml", + "python/agui/strands/capabilities/memory/__init__.py", + "python/agui/strands/capabilities/memory/session.py", "python/http/autogen/base/README.md", "python/http/autogen/base/gitignore.template", "python/http/autogen/base/main.py", @@ -971,7 +991,8 @@ Thumbs.db `; exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/googleadk/base/main.py should match snapshot 1`] = ` -"from google.adk.agents import Agent +"import os +from google.adk.agents import Agent from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService @@ -979,55 +1000,1063 @@ from a2a.types import AgentCapabilities, AgentCard, AgentSkill from bedrock_agentcore.runtime import serve_a2a from model.load import load_model +load_model() # Sets GOOGLE_API_KEY env var (returns None) + + +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers.""" + return a + b + + +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + +AGENT_INSTRUCTION = """ +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}} +""" + +agent = Agent( + model="gemini-2.5-flash", + name="{{ name }}", + description="A helpful assistant that can use tools.", + instruction=AGENT_INSTRUCTION, + tools=tools, +) + +runner = Runner( + app_name=agent.name, + agent=agent, + session_service=InMemorySessionService(), +) + +card = AgentCard( + name=agent.name, + description=agent.description, + url="http://localhost:9000/", + version="0.1.0", + capabilities=AgentCapabilities(streaming=True), + skills=[ + AgentSkill( + id="tools", + name="tools", + description="Use tools to help answer questions", + tags=["tools"], + ) + ], + default_input_modes=["text"], + default_output_modes=["text"], +) + +if __name__ == "__main__": + serve_a2a(A2aAgentExecutor(runner=runner), card) +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/googleadk/base/model/__init__.py should match snapshot 1`] = ` +"# Package marker +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/googleadk/base/model/load.py should match snapshot 1`] = ` +"import os +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> None: + """ + Set up Gemini API key authentication. + Uses AgentCore Identity for API key management in deployed environments, + and falls back to .env file for local development. + Sets the GOOGLE_API_KEY environment variable for the Google ADK. + """ + api_key = _get_api_key() + # Use Google AI Studios API Key Authentication. + # https://google.github.io/adk-docs/agents/models/#google-ai-studio + os.environ["GOOGLE_API_KEY"] = api_key + # Set to TRUE is using Google Vertex AI, Set to FALSE for Google AI Studio + os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "FALSE" +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/googleadk/base/pyproject.toml should match snapshot 1`] = ` +"[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore A2A Agent using Google ADK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "a2a-sdk >= 0.2.0", + "aws-opentelemetry-distro", + "bedrock-agentcore[a2a] >= 1.0.3", + "google-adk >= 1.0.0", + "google-genai >= 1.0.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/README.md should match snapshot 1`] = ` +"# {{ name }} + +An A2A (Agent-to-Agent) agent deployed on Amazon Bedrock AgentCore using LangChain + LangGraph. + +## Overview + +This agent implements the A2A protocol using LangGraph, enabling agent-to-agent communication. + +## Local Development + +\`\`\`bash +uv sync +uv run python main.py +\`\`\` + +The agent starts on port 9000. + +## Deploy + +\`\`\`bash +agentcore deploy +\`\`\` +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/gitignore.template should match snapshot 1`] = ` +"# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/main.py should match snapshot 1`] = ` +"import os +from langchain_core.tools import tool +from langgraph.prebuilt import create_react_agent +from opentelemetry.instrumentation.langchain import LangchainInstrumentor +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Part, TextPart +from a2a.utils import new_task +from bedrock_agentcore.runtime import serve_a2a +from model.load import load_model + +LangchainInstrumentor().instrument() + + +@tool +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers.""" + return a + b + + +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +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}} +""" + +model = load_model() +graph = create_react_agent(model, tools=tools, prompt=SYSTEM_PROMPT) + + +class LangGraphA2AExecutor(AgentExecutor): + """Wraps a LangGraph CompiledGraph as an a2a-sdk AgentExecutor.""" + + def __init__(self, graph): + self.graph = graph + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + task = context.current_task or new_task(context.message) + if not context.current_task: + await event_queue.enqueue_event(task) + updater = TaskUpdater(event_queue, task.id, task.context_id) + + user_text = context.get_user_input() + result = await self.graph.ainvoke({"messages": [("user", user_text)]}) + response = result["messages"][-1].content + + await updater.add_artifact([Part(root=TextPart(text=response))]) + await updater.complete() + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + pass + + +card = AgentCard( + name="{{ name }}", + description="A LangGraph agent on Bedrock AgentCore", + url="http://localhost:9000/", + version="0.1.0", + capabilities=AgentCapabilities(streaming=True), + skills=[ + AgentSkill( + id="tools", + name="tools", + description="Use tools to help answer questions", + tags=["tools"], + ) + ], + default_input_modes=["text"], + default_output_modes=["text"], +) + +if __name__ == "__main__": + serve_a2a(LangGraphA2AExecutor(graph), card) +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/model/__init__.py should match snapshot 1`] = ` +"# Package marker +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/model/load.py should match snapshot 1`] = ` +"{{#if (eq modelProvider "Bedrock")}} +from langchain_aws import ChatBedrock + +# Uses global inference profile for Claude Sonnet 4.5 +# https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html +MODEL_ID = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + + +def load_model() -> ChatBedrock: + """Get Bedrock model client using IAM credentials.""" + return ChatBedrock(model_id=MODEL_ID) +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import os +from langchain_anthropic import ChatAnthropic +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> ChatAnthropic: + """Get authenticated Anthropic model client.""" + return ChatAnthropic( + model="claude-sonnet-4-5-20250929", + api_key=_get_api_key() + ) +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import os +from langchain_openai import ChatOpenAI +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> ChatOpenAI: + """Get authenticated OpenAI model client.""" + return ChatOpenAI( + model="gpt-4.1", + api_key=_get_api_key() + ) +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import os +from langchain_google_genai import ChatGoogleGenerativeAI +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> ChatGoogleGenerativeAI: + """Get authenticated Gemini model client.""" + return ChatGoogleGenerativeAI( + model="gemini-2.5-flash", + api_key=_get_api_key() + ) +{{/if}} +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/pyproject.toml should match snapshot 1`] = ` +"[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore A2A Agent using LangChain + LangGraph" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "a2a-sdk >= 0.2.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", + {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", + {{/if}}"aws-opentelemetry-distro", + "opentelemetry-instrumentation-langchain >= 0.59.0", + "bedrock-agentcore[a2a] >= 1.0.3", + "botocore[crt] >= 1.35.0", + "langgraph >= 0.2.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/README.md should match snapshot 1`] = ` +"# {{ name }} + +An A2A (Agent-to-Agent) agent deployed on Amazon Bedrock AgentCore using Strands SDK. + +## Overview + +This agent implements the A2A protocol, enabling agent-to-agent communication. Other agents can discover and interact with this agent via the \`/.well-known/agent-card.json\` endpoint. + +## Local Development + +\`\`\`bash +uv sync +uv run python main.py +\`\`\` + +The agent starts on port 9000. + +## Deploy + +\`\`\`bash +agentcore deploy +\`\`\` +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/gitignore.template should match snapshot 1`] = ` +"# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/main.py should match snapshot 1`] = ` +"from strands import Agent, tool +from strands.multiagent.a2a.executor import StrandsA2AExecutor +from bedrock_agentcore.runtime import serve_a2a +from model.load import load_model +{{#if hasMemory}} +from memory.session import get_memory_session_manager +{{/if}} +{{#if sessionStorageMountPath}} +import os +{{/if}} + + +@tool +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers.""" + return a + b + + +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +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 hasMemory}} +def agent_factory(): + cache = {} + def get_or_create_agent(session_id, user_id): + key = f"{session_id}/{user_id}" + if key not in cache: + cache[key] = Agent( + model=load_model(), + session_manager=get_memory_session_manager(session_id, user_id), + system_prompt=SYSTEM_PROMPT, + tools=tools, + ) + return cache[key] + return get_or_create_agent + +get_or_create_agent = agent_factory() +agent = get_or_create_agent("default-session", "default-user") +{{else}} +agent = Agent( + model=load_model(), + system_prompt=SYSTEM_PROMPT, + tools=tools, +) +{{/if}} + +if __name__ == "__main__": + serve_a2a(StrandsA2AExecutor(agent)) +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/model/__init__.py should match snapshot 1`] = ` +"# Package marker +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/model/load.py should match snapshot 1`] = ` +"{{#if (eq modelProvider "Bedrock")}} +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") +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import os + +from strands.models.anthropic import AnthropicModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> AnthropicModel: + """Get authenticated Anthropic model client.""" + return AnthropicModel( + client_args={"api_key": _get_api_key()}, + model_id="claude-sonnet-4-5-20250929", + max_tokens=5000, + ) +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import os + +from strands.models.openai import OpenAIModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> OpenAIModel: + """Get authenticated OpenAI model client.""" + return OpenAIModel( + client_args={"api_key": _get_api_key()}, + model_id="gpt-4.1", + ) +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import os + +from strands.models.gemini import GeminiModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> GeminiModel: + """Get authenticated Gemini model client.""" + return GeminiModel( + client_args={"api_key": _get_api_key()}, + model_id="gemini-2.5-flash", + ) +{{/if}} +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/pyproject.toml should match snapshot 1`] = ` +"[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore A2A Agent using Strands SDK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", + {{/if}}"a2a-sdk[all] >= 0.2.0", + "aws-opentelemetry-distro", + "bedrock-agentcore[a2a] >= 1.0.3", + "botocore[crt] >= 1.35.0", + {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", + {{/if}}{{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", + {{/if}}"strands-agents >= 1.13.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/capabilities/memory/__init__.py should match snapshot 1`] = ` +"# Package marker +" +`; + +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}} +from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager + +MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") +REGION = os.getenv("AWS_REGION") + +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")}} + f"/users/{actor_id}/facts": RetrievalConfig(top_k=3, relevance_score=0.5), +{{/if}} +{{#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 "SUMMARIZATION")}} + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), +{{/if}} + } +{{/if}} + + return AgentCoreMemorySessionManager( + AgentCoreMemoryConfig( + memory_id=MEMORY_ID, + session_id=session_id, + actor_id=actor_id, +{{#if memoryProviders.[0].strategies.length}} + retrieval_config=retrieval_config, +{{/if}} + ), + REGION + ) +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/googleadk/base/README.md should match snapshot 1`] = ` +"# {{ name }} + +An AG-UI agent deployed on Amazon Bedrock AgentCore using Google ADK. + +## Overview + +This agent implements the AG-UI protocol using Google's Agent Development Kit, enabling rich agent-user interaction via the AG-UI event stream. + +## Local Development + +\`\`\`bash +uv sync +uv run python main.py +\`\`\` + +The agent starts on port 8080 and serves requests at \`/invocations\`. + +## Health Check + +\`\`\` +GET /ping +\`\`\` + +Returns \`{"status": "healthy"}\`. + +## Deploy + +\`\`\`bash +agentcore deploy +\`\`\` +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/googleadk/base/gitignore.template should match snapshot 1`] = ` +"# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +" +`; -def add_numbers(a: int, b: int) -> int: - """Return the sum of two numbers.""" - return a + b +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/googleadk/base/main.py should match snapshot 1`] = ` +"import os +import uvicorn +from google.adk.agents import LlmAgent +from ag_ui_adk import ADKAgent, AGUIToolset, create_adk_app +from model.load import load_model +load_model() -agent = Agent( - model=load_model(), +agent = LlmAgent( name="{{ name }}", - description="A helpful assistant that can use tools.", - instruction="You are a helpful assistant. Use tools when appropriate.", - tools=[add_numbers], + model="gemini-2.5-flash", + instruction="You are a helpful assistant.", + tools=[AGUIToolset()], ) -runner = Runner( - app_name=agent.name, - agent=agent, - session_service=InMemorySessionService(), +adk_agent = ADKAgent( + adk_agent=agent, + app_name="{{ name }}", + use_in_memory_services=True, ) -card = AgentCard( - name=agent.name, - description=agent.description, - url="http://localhost:9000/", - version="0.1.0", - capabilities=AgentCapabilities(streaming=True), - skills=[ - AgentSkill( - id="tools", - name="tools", - description="Use tools to help answer questions", - tags=["tools"], - ) - ], - default_input_modes=["text"], - default_output_modes=["text"], -) +app = create_adk_app(adk_agent, path="/invocations") + + +@app.get("/ping") +async def ping(): + return {"status": "healthy"} + if __name__ == "__main__": - serve_a2a(A2aAgentExecutor(runner=runner), card) + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/googleadk/base/model/__init__.py should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/googleadk/base/model/__init__.py should match snapshot 1`] = ` "# Package marker " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/googleadk/base/model/load.py should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/googleadk/base/model/load.py should match snapshot 1`] = ` "import os from bedrock_agentcore.identity.auth import requires_api_key @@ -1072,7 +2101,7 @@ def load_model() -> None: " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/googleadk/base/pyproject.toml should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/googleadk/base/pyproject.toml should match snapshot 1`] = ` "[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -1080,15 +2109,19 @@ build-backend = "hatchling.build" [project] name = "{{ name }}" version = "0.1.0" -description = "AgentCore A2A Agent using Google ADK" +description = "AgentCore AG-UI Agent using Google ADK" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk >= 0.2.0", - "aws-opentelemetry-distro", - "bedrock-agentcore[a2a] >= 1.0.3", - "google-adk >= 1.0.0", + "ag-ui-adk >= 0.6.0", + "ag-ui-protocol >= 0.1.10", + "bedrock-agentcore >= 1.0.3", + "fastapi >= 0.115.12", + "google-adk >= 1.16.0", "google-genai >= 1.0.0", + "opentelemetry-distro", + "opentelemetry-exporter-otlp", + "uvicorn >= 0.34.3", ] [tool.hatch.build.targets.wheel] @@ -1096,14 +2129,14 @@ packages = ["."] " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/README.md should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/langchain_langgraph/base/README.md should match snapshot 1`] = ` "# {{ name }} -An A2A (Agent-to-Agent) agent deployed on Amazon Bedrock AgentCore using LangChain + LangGraph. +An AG-UI agent deployed on Amazon Bedrock AgentCore using LangChain + LangGraph. ## Overview -This agent implements the A2A protocol using LangGraph, enabling agent-to-agent communication. +This agent implements the AG-UI protocol using LangGraph, enabling seamless frontend-to-agent communication with support for streaming, tool calls, and frontend-injected tools. ## Local Development @@ -1112,7 +2145,7 @@ uv sync uv run python main.py \`\`\` -The agent starts on port 9000. +The agent starts on port 8080. ## Deploy @@ -1122,7 +2155,7 @@ agentcore deploy " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/gitignore.template should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/langchain_langgraph/base/gitignore.template should match snapshot 1`] = ` "# Environment variables .env @@ -1167,17 +2200,26 @@ Thumbs.db " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/main.py should match snapshot 1`] = ` -"from langchain_core.tools import tool -from langgraph.prebuilt import create_react_agent -from a2a.server.agent_execution import AgentExecutor, RequestContext -from a2a.server.events import EventQueue -from a2a.server.tasks import TaskUpdater -from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Part, TextPart -from a2a.utils import new_task -from bedrock_agentcore.runtime import serve_a2a +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/langchain_langgraph/base/main.py should match snapshot 1`] = ` +"import os + +os.environ["LANGGRAPH_FAST_API"] = "true" + +import uvicorn +from typing import Any, List +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from langgraph.graph import StateGraph, START +from langgraph.graph.message import MessagesState +from langgraph.checkpoint.memory import MemorySaver +from langgraph.prebuilt import ToolNode, tools_condition +from langchain_core.tools import tool +from opentelemetry.instrumentation.langchain import LangchainInstrumentor +from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint from model.load import load_model +LangchainInstrumentor().instrument() + @tool def add_numbers(a: int, b: int) -> int: @@ -1185,62 +2227,63 @@ def add_numbers(a: int, b: int) -> int: return a + b +backend_tools = [add_numbers] model = load_model() -graph = create_react_agent(model, tools=[add_numbers]) - - -class LangGraphA2AExecutor(AgentExecutor): - """Wraps a LangGraph CompiledGraph as an a2a-sdk AgentExecutor.""" - def __init__(self, graph): - self.graph = graph - async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: - task = context.current_task or new_task(context.message) - if not context.current_task: - await event_queue.enqueue_event(task) - updater = TaskUpdater(event_queue, task.id, task.context_id) +class AgentState(MessagesState): + tools: List[Any] - user_text = context.get_user_input() - result = await self.graph.ainvoke({"messages": [("user", user_text)]}) - response = result["messages"][-1].content - await updater.add_artifact([Part(root=TextPart(text=response))]) - await updater.complete() +def chat_node(state: AgentState): + bound_model = model.bind_tools( + [*state.get("tools", []), *backend_tools], + ) + response = bound_model.invoke(state["messages"]) + return {"messages": [response]} - async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: - pass +builder = StateGraph(AgentState) +builder.add_node("chat", chat_node) +builder.add_node("tools", ToolNode(tools=backend_tools)) +builder.add_edge(START, "chat") +builder.add_conditional_edges("chat", tools_condition) +builder.add_edge("tools", "chat") +graph = builder.compile(checkpointer=MemorySaver()) -card = AgentCard( +agent = LangGraphAgent( name="{{ name }}", - description="A LangGraph agent on Bedrock AgentCore", - url="http://localhost:9000/", - version="0.1.0", - capabilities=AgentCapabilities(streaming=True), - skills=[ - AgentSkill( - id="tools", - name="tools", - description="Use tools to help answer questions", - tags=["tools"], - ) - ], - default_input_modes=["text"], - default_output_modes=["text"], + graph=graph, + description="A helpful assistant", +) + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], ) +add_langgraph_fastapi_endpoint(app=app, agent=agent, path="/invocations") + + +@app.get("/ping") +async def ping(): + return {"status": "healthy"} + + if __name__ == "__main__": - serve_a2a(LangGraphA2AExecutor(graph), card) + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/model/__init__.py should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/langchain_langgraph/base/model/__init__.py should match snapshot 1`] = ` "# Package marker " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/model/load.py should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/langchain_langgraph/base/model/load.py should match snapshot 1`] = ` "{{#if (eq modelProvider "Bedrock")}} from langchain_aws import ChatBedrock @@ -1367,7 +2410,7 @@ def load_model() -> ChatGoogleGenerativeAI: " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/langchain_langgraph/base/pyproject.toml should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/langchain_langgraph/base/pyproject.toml should match snapshot 1`] = ` "[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -1375,19 +2418,25 @@ build-backend = "hatchling.build" [project] name = "{{ name }}" version = "0.1.0" -description = "AgentCore A2A Agent using LangChain + LangGraph" +description = "AgentCore AG-UI Agent using LangChain + LangGraph" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk >= 0.2.0", + "ag-ui-langgraph >= 0.0.31", + "ag-ui-protocol >= 0.1.10", {{#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", {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", {{/if}}"aws-opentelemetry-distro", - "bedrock-agentcore[a2a] >= 1.0.3", + "opentelemetry-instrumentation-langchain >= 0.59.0", + "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", - "langgraph >= 0.2.0", + "langgraph >= 0.3.25", + "langchain >= 0.3.0", + "langchain-core >= 0.3.0", + "fastapi >= 0.115.12", + "uvicorn >= 0.34.3", ] [tool.hatch.build.targets.wheel] @@ -1395,14 +2444,14 @@ packages = ["."] " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/README.md should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/base/README.md should match snapshot 1`] = ` "# {{ name }} -An A2A (Agent-to-Agent) agent deployed on Amazon Bedrock AgentCore using Strands SDK. +An AG-UI agent deployed on Amazon Bedrock AgentCore using Strands SDK. ## Overview -This agent implements the A2A protocol, enabling agent-to-agent communication. Other agents can discover and interact with this agent via the \`/.well-known/agent-card.json\` endpoint. +This agent implements the AG-UI protocol, enabling streaming agent-to-UI communication. The agent exposes an \`/invocations\` endpoint that accepts AG-UI protocol requests and streams responses back to the client. ## Local Development @@ -1411,7 +2460,7 @@ uv sync uv run python main.py \`\`\` -The agent starts on port 9000. +The agent starts on port 8080. ## Deploy @@ -1421,7 +2470,7 @@ agentcore deploy " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/gitignore.template should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/base/gitignore.template should match snapshot 1`] = ` "# Environment variables .env @@ -1466,10 +2515,16 @@ Thumbs.db " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/main.py should match snapshot 1`] = ` -"from strands import Agent, tool -from strands.multiagent.a2a.executor import StrandsA2AExecutor -from bedrock_agentcore.runtime import serve_a2a +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/base/main.py should match snapshot 1`] = ` +"import os + +# Suppress OpenTelemetry warnings during local development; remove for production +if os.getenv("LOCAL_DEV") == "1": + os.environ["OTEL_SDK_DISABLED"] = "true" + +import uvicorn +from strands import Agent, tool +from ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app from model.load import load_model {{#if hasMemory}} from memory.session import get_memory_session_manager @@ -1484,42 +2539,35 @@ def add_numbers(a: int, b: int) -> int: tools = [add_numbers] -{{#if hasMemory}} -def agent_factory(): - cache = {} - def get_or_create_agent(session_id, user_id): - key = f"{session_id}/{user_id}" - if key not in cache: - cache[key] = Agent( - model=load_model(), - session_manager=get_memory_session_manager(session_id, user_id), - system_prompt="You are a helpful assistant. Use tools when appropriate.", - tools=tools, - ) - return cache[key] - return get_or_create_agent - -get_or_create_agent = agent_factory() -agent = get_or_create_agent("default-session", "default-user") -{{else}} agent = Agent( model=load_model(), system_prompt="You are a helpful assistant. Use tools when appropriate.", tools=tools, ) + +{{#if hasMemory}} +def session_manager_provider(input_data): + return get_memory_session_manager(input_data.thread_id, "default-user") + +config = StrandsAgentConfig(session_manager_provider=session_manager_provider) +{{else}} +config = StrandsAgentConfig() {{/if}} +agui_agent = StrandsAgent(agent=agent, name="{{ name }}", description="A helpful assistant", config=config) +app = create_strands_app(agui_agent, path="/invocations", ping_path="/ping") + if __name__ == "__main__": - serve_a2a(StrandsA2AExecutor(agent)) + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/model/__init__.py should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/base/model/__init__.py should match snapshot 1`] = ` "# Package marker " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/model/load.py should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/base/model/load.py should match snapshot 1`] = ` "{{#if (eq modelProvider "Bedrock")}} from strands.models.bedrock import BedrockModel @@ -1646,7 +2694,7 @@ def load_model() -> GeminiModel: " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/base/pyproject.toml should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/base/pyproject.toml should match snapshot 1`] = ` "[build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -1654,18 +2702,22 @@ build-backend = "hatchling.build" [project] name = "{{ name }}" version = "0.1.0" -description = "AgentCore A2A Agent using Strands SDK" +description = "AgentCore AG-UI Agent using Strands SDK" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.12" dependencies = [ {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", - {{/if}}"a2a-sdk[all] >= 0.2.0", + {{/if}}"ag-ui-strands >= 0.1.7", + "ag-ui-protocol >= 0.1.10", "aws-opentelemetry-distro", - "bedrock-agentcore[a2a] >= 1.0.3", + "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", + "fastapi >= 0.115.12", {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}{{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", - {{/if}}"strands-agents >= 1.13.0", + {{/if}}"strands-agents >= 1.15.0", + "strands-agents-tools >= 0.2.14", + "uvicorn >= 0.34.3", ] [tool.hatch.build.targets.wheel] @@ -1673,13 +2725,14 @@ packages = ["."] " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/capabilities/memory/__init__.py should match snapshot 1`] = ` +exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/capabilities/memory/__init__.py should match snapshot 1`] = ` "# Package marker " `; -exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/capabilities/memory/session.py should match snapshot 1`] = ` +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}} @@ -1688,10 +2741,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")}} @@ -1833,6 +2890,66 @@ add_numbers_tool = FunctionTool( # Define a collection of tools used by the model tools = [add_numbers_tool] +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([ + FunctionTool(file_read, description="Read a file from persistent storage. The path is relative to the storage root."), + FunctionTool(file_write, description="Write content to a file in persistent storage. The path is relative to the storage root."), + FunctionTool(list_files, description="List files in persistent storage. The directory is relative to the storage root."), +]) +{{/if}} + +SYSTEM_MESSAGE = """ +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}} +""" @app.entrypoint async def invoke(payload, context): @@ -1846,7 +2963,7 @@ async def invoke(payload, context): name="{{ name }}", model_client=load_model(), tools=tools + mcp_tools, - system_message="You are a helpful assistant. Use tools when appropriate.", + system_message=SYSTEM_MESSAGE, ) # Process the user prompt @@ -2201,6 +3318,66 @@ def add_numbers(a: int, b: int) -> int: return a + b +# Define a collection of tools used by the model +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + +AGENT_INSTRUCTION = """ +I can answer your questions using the knowledge I have! +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +""" + # Get MCP Toolset {{#if hasGateway}} mcp_toolset = get_all_gateway_mcp_toolsets() @@ -2223,8 +3400,8 @@ agent = Agent( model=MODEL_ID, name="{{ name }}", description="Agent to answer questions", - instruction="I can answer your questions using the knowledge I have!", - tools=mcp_toolset + [add_numbers], + instruction=AGENT_INSTRUCTION, + tools=mcp_toolset + tools, ) @@ -2529,6 +3706,7 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/ht from langchain_core.messages import HumanMessage from langgraph.prebuilt import create_react_agent from langchain.tools import tool +from opentelemetry.instrumentation.langchain import LangchainInstrumentor from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} @@ -2537,6 +3715,8 @@ from mcp_client.client import get_all_gateway_mcp_client from mcp_client.client import get_streamable_http_mcp_client {{/if}} +LangchainInstrumentor().instrument() + app = BedrockAgentCoreApp() log = app.logger @@ -2559,6 +3739,66 @@ def add_numbers(a: int, b: int) -> int: # Define a collection of tools used by the model tools = [add_numbers] +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +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}} +""" + @app.entrypoint async def invoke(payload, context): @@ -2577,7 +3817,7 @@ 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) + graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=SYSTEM_PROMPT) # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") @@ -2828,6 +4068,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", + "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", "langchain-mcp-adapters >= 0.1.11", @@ -2981,6 +4222,68 @@ def add_numbers(a: int, b: int) -> int: return a + b +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@function_tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@function_tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@function_tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + +INSTRUCTIONS = """ +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 the agent execution async def main(query): ensure_credentials_loaded() @@ -2990,8 +4293,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=mcp_servers, - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result @@ -2999,8 +4303,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=[], - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result @@ -3011,8 +4316,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=active_servers, - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result @@ -3020,8 +4326,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=[], - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result @@ -3303,6 +4610,9 @@ from mcp_client.client import get_streamable_http_mcp_client {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} +{{#if sessionStorageMountPath}} +import os +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -3324,11 +4634,70 @@ def add_numbers(a: int, b: int) -> int: return a+b tools.append(add_numbers) +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + # Add MCP client to tools if available 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}} +""" {{#if hasMemory}} def agent_factory(): @@ -3340,9 +4709,7 @@ def agent_factory(): cache[key] = Agent( model=load_model(), session_manager=get_memory_session_manager(session_id, user_id), - system_prompt=""" - You are a helpful assistant. Use tools when appropriate. - """, + system_prompt=SYSTEM_PROMPT, tools=tools ) return cache[key] @@ -3356,9 +4723,7 @@ def get_or_create_agent(): if _agent is None: _agent = Agent( model=load_model(), - system_prompt=""" - You are a helpful assistant. Use tools when appropriate. - """, + system_prompt=SYSTEM_PROMPT, tools=tools ) return _agent @@ -3641,6 +5006,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}} @@ -3649,10 +5015,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")}} @@ -3821,27 +5191,47 @@ exports[`Assets Directory Snapshots > Root-level assets > AGENTS.md should match This directory stores: -- Template assets for agents written in different Languages, SDKs and having different configurations +- Template assets for agents written in different languages, SDKs, and configurations - Container templates (\`container/python/\`) with \`Dockerfile\` and \`.dockerignore\` for Container build agents +- Vended documentation (\`README.md\`, \`agents/AGENTS.md\`) copied into user projects at create time +- CDK project template (\`cdk/\`) using \`@aws/agentcore-cdk\` L3 constructs +- Evaluator templates (\`evaluators/\`) for code-based evaluators +- MCP tool templates (\`mcp/\`) for Lambda and AgentCoreRuntime compute ### Directory Layout \`\`\` assets/ -├── python/ # Framework templates (one per SDK) -│ ├── strands/ -│ ├── langchain_langgraph/ -│ ├── googleadk/ -│ ├── openaiagents/ -│ └── autogen/ +├── README.md # Vended to project root as project README +├── AGENTS.md # This file — internal dev context +├── agents/ +│ └── AGENTS.md # Vended to project root for AI coding assistants +├── python/ # Framework templates (one per SDK per protocol) +│ ├── http/ # HTTP protocol agents +│ │ ├── strands/ +│ │ ├── langchain_langgraph/ +│ │ ├── googleadk/ +│ │ ├── openaiagents/ +│ │ └── autogen/ +│ ├── mcp/ # MCP protocol agents +│ │ └── standalone/ +│ └── a2a/ # A2A protocol agents +│ ├── strands/ +│ ├── langchain_langgraph/ +│ └── googleadk/ +├── typescript/ # TypeScript agent templates ├── container/ # Container build templates │ └── python/ │ ├── Dockerfile │ └── dockerignore.template -└── agents/ # AGENTS.md vended to user projects +├── cdk/ # CDK project template (@aws/agentcore-cdk) +├── evaluators/ # Code-based evaluator templates +└── mcp/ # MCP tool templates (Lambda + AgentCoreRuntime) + ├── python/ + └── python-lambda/ \`\`\` -The rendering logic is rooted in the \`AgentEnvSpec\` and must ALWAYS respect the configuration in the Spec. +The rendering logic is rooted in the \`AgentEnvSpec\` and must ALWAYS respect the configuration in the spec. For Container builds, \`BaseRenderer.render()\` automatically copies the \`container//\` templates (Dockerfile, .dockerignore) into the agent directory when \`buildType === 'Container'\`. @@ -3850,10 +5240,13 @@ For Container builds, \`BaseRenderer.render()\` automatically copies the \`conta - Always make sure the templates are as close to working code as possible - AVOID as much as possible using any conditionals within the templates +- Test template rendering with \`agentcore add agent\` for each framework/protocol combination ## How to use the assets in this directory -- These assets are rendered by the CLI's template renderer in \`src/cli/templates/\`. +- These assets are rendered by the CLI's template renderer in \`src/cli/templates/\` +- The \`README.md\` and \`agents/AGENTS.md\` are copied verbatim (no template rendering) during project creation +- The \`.llm-context/\` files are sourced from \`src/schema/llm-compacted/\` and written during init " `; @@ -3865,14 +5258,19 @@ This project was created with the [AgentCore CLI](https://github.com/aws/agentco ## Project Structure \`\`\` -. my-project/ +├── AGENTS.md # AI coding assistant context ├── agentcore/ -│ ├── .env.local # API keys (gitignored) -│ ├── agentcore.json # Resource specifications -│ ├── aws-targets.json # Deployment targets -│ └── cdk/ # CDK infrastructure -├── app/ # Application code +│ ├── agentcore.json # Project config (agents, memories, credentials, gateways, evaluators) +│ ├── aws-targets.json # Deployment targets (account + region) +│ ├── .env.local # Secrets — API keys (gitignored) +│ ├── .llm-context/ # TypeScript type definitions for AI assistants +│ │ ├── agentcore.ts # AgentCoreProjectSpec types +│ │ ├── aws-targets.ts # Deployment target types +│ │ └── mcp.ts # Gateway and MCP tool types +│ └── cdk/ # CDK infrastructure (@aws/agentcore-cdk) +├── app/ # Agent application code +└── evaluators/ # Custom evaluator code (if any) \`\`\` ## Getting Started @@ -3880,7 +5278,9 @@ my-project/ ### Prerequisites - **Node.js** 20.x or later -- **uv** for Python agents ([install](https://docs.astral.sh/uv/getting-started/installation/)) +- **Python 3.10+** and **uv** for Python agents ([install uv](https://docs.astral.sh/uv/getting-started/installation/)) +- **AWS credentials** configured (\`aws configure\` or environment variables) +- **Docker** (only for Container build agents) ### Development @@ -3898,44 +5298,62 @@ Deploy to AWS: agentcore deploy \`\`\` -Or use CDK directly: +## Commands -\`\`\`bash -cd agentcore/cdk -npx cdk deploy -\`\`\` +| Command | Description | +| --- | --- | +| \`agentcore create\` | Create a new AgentCore project | +| \`agentcore add\` | Add resources (agent, memory, credential, gateway, evaluator, policy) | +| \`agentcore remove\` | Remove resources | +| \`agentcore dev\` | Run agent locally with hot-reload | +| \`agentcore deploy\` | Deploy to AWS via CDK | +| \`agentcore status\` | Show deployment status | +| \`agentcore invoke\` | Invoke agent (local or deployed) | +| \`agentcore logs\` | View agent logs | +| \`agentcore traces\` | View agent traces | +| \`agentcore eval\` | Run evaluations | +| \`agentcore package\` | Package agent artifacts | +| \`agentcore validate\` | Validate configuration | +| \`agentcore pause\` | Pause a deployed agent | +| \`agentcore resume\` | Resume a paused agent | +| \`agentcore fetch\` | Fetch remote resource definitions | +| \`agentcore import\` | Import existing resources | +| \`agentcore update\` | Check for CLI updates | ## Configuration -Edit the JSON files in \`agentcore/\` to configure your agents, memory, and credentials. See \`agentcore/.llm-context/\` for -type definitions and validation constraints. +Edit the JSON files in \`agentcore/\` to configure your project. See \`agentcore/.llm-context/\` for type definitions and validation constraints. -The project uses a **flat resource model** where agents, memories, and credentials are top-level arrays in -\`agentcore.json\`. +The project uses a **flat resource model** — agents, memories, credentials, gateways, evaluators, and policies are top-level arrays in \`agentcore.json\`. Resources are independent; agents discover memories and credentials at runtime via environment variables or SDK calls. -## Commands +## Resources -| Command | Description | -| -------------------- | ----------------------------------------------- | -| \`agentcore create\` | Create a new AgentCore project | -| \`agentcore add\` | Add resources (agent, memory, credential, target) | -| \`agentcore remove\` | Remove resources | -| \`agentcore dev\` | Run agent locally | -| \`agentcore deploy\` | Deploy to AWS | -| \`agentcore status\` | Show deployment status | -| \`agentcore invoke\` | Invoke agent (local or deployed) | -| \`agentcore package\` | Package agent artifacts | -| \`agentcore validate\` | Validate configuration | -| \`agentcore update\` | Check for CLI updates | +| Resource | Purpose | +| --- | --- | +| Agent (runtime) | HTTP, MCP, or A2A agent deployed to AgentCore Runtime | +| Memory | Persistent context storage with configurable strategies | +| Credential | API key or OAuth credential providers | +| Gateway | MCP gateway that routes tool calls to targets | +| Gateway Target | Tool implementation (Lambda, MCP server, OpenAPI, Smithy, API Gateway) | +| Evaluator | Custom LLM-as-a-Judge or code-based evaluation | +| Online Eval Config | Continuous evaluation pipeline for deployed agents | +| Policy | Cedar authorization policies for gateway tools | ### Agent Types -- **Template agents**: Created from framework templates (Strands, LangChain_LangGraph, CrewAI, GoogleADK, OpenAIAgents) +- **Template agents**: Created from framework templates (Strands, LangChain/LangGraph, GoogleADK, OpenAI Agents, Autogen) - **BYO agents**: Bring your own code with \`agentcore add agent --type byo\` +- **Import agents**: Import existing Bedrock agents with \`agentcore import\` + +### Build Types + +- **CodeZip**: Python source packaged as a zip and deployed directly to AgentCore Runtime +- **Container**: Docker image built via CodeBuild (ARM64), pushed to ECR, and deployed to AgentCore Runtime ## Documentation -- [AgentCore CLI Documentation](https://github.com/aws/agentcore-cli) +- [AgentCore CLI](https://github.com/aws/agentcore-cli) +- [AgentCore CDK Constructs](https://github.com/aws/agentcore-l3-cdk-constructs) - [Amazon Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/) " `; @@ -3945,103 +5363,114 @@ exports[`Assets Directory Snapshots > Root-level assets > agents/AGENTS.md shoul This project contains configuration and infrastructure for an Amazon Bedrock AgentCore application. -The \`agentcore/\` directory serves as a declarative model of an AgentCore project along with a concrete implementation -through the \`agentcore/cdk/\` project which is modeled to take the configs as input. The project uses a **flat resource -model** where agents, memories, and credentials are top-level arrays. +The \`agentcore/\` directory is a declarative model of the project. The \`agentcore/cdk/\` subdirectory uses the +\`@aws/agentcore-cdk\` L3 constructs to deploy the configuration to AWS. ## Mental Model -The project uses a **flat resource model**. Agents, memories, and credentials are independent top-level arrays in -\`agentcore.json\`. There is no binding or attachment between resources in the schema — each resource is provisioned -independently. To use a memory or credential from an agent, the application code discovers the resource at runtime -(e.g., via environment variables or SDK calls). Tags defined in \`agentcore.json\` flow through to deployed CloudFormation resources. +The project uses a **flat resource model**. Agents, memories, credentials, gateways, evaluators, and policies are +independent top-level arrays in \`agentcore.json\`. There is no binding between resources in the schema — each resource is +provisioned independently. Agents discover memories and credentials at runtime via environment variables or SDK calls. +Tags defined in \`agentcore.json\` flow through to deployed CloudFormation resources. ## Critical Invariants -1. **Schema-First Authority:** The \`.json\` files are the absolute source of truth. Do not attempt to modify agent - behavior by editing the generated CDK code in \`cdk/\`. -2. **Resource Identity:** The \`name\` field in the schema determines the CloudFormation Logical ID. - - **Renaming** an agent or target will **destroy and recreate** that resource. - - **Modifying** other fields (descriptions, config) will update the resource **in-place**. -3. **1:1 Validation:** The schema maps directly to valid CloudFormation. If your JSON conforms to the types in - \`.llm-context/\`, it will deploy successfully. -4. **Resource Removal:** To remove all resources, use \`agentcore remove all\`. To tear down deployed infrastructure, run - \`agentcore deploy\` after removal — it will detect the empty state and offer a teardown flow. +1. **Schema-First Authority:** The \`.json\` files are the source of truth. Do not modify agent behavior by editing + generated CDK code in \`cdk/\`. +2. **Resource Identity:** The \`name\` field determines the CloudFormation Logical ID. + - **Renaming** a resource will **destroy and recreate** it. + - **Modifying** other fields will update the resource **in-place**. +3. **Schema Validation:** If your JSON conforms to the types in \`.llm-context/\`, it will deploy successfully. Run + \`agentcore validate\` to check. +4. **Resource Removal:** Use \`agentcore remove\` to remove resources. Run \`agentcore deploy\` after removal to tear down + deployed infrastructure. ## Directory Structure \`\`\` -myNewProject/ -├── AGENTS.md # This file - AI coding assistant context -├── agentcore/ # AgentCore configuration directory +myProject/ +├── AGENTS.md # This file — AI coding assistant context +├── agentcore/ │ ├── agentcore.json # Main project config (AgentCoreProjectSpec) -│ ├── aws-targets.json # Deployment targets -│ ├── .llm-context/ # TypeScript type definitions for AI coding assistants -│ │ ├── README.md # Guide to using the schema files +│ ├── aws-targets.json # Deployment targets (account + region) +│ ├── .env.local # Secrets — API keys (gitignored) +│ ├── .llm-context/ # TypeScript type definitions for AI assistants +│ │ ├── README.md # Guide to using schema files │ │ ├── agentcore.ts # AgentCoreProjectSpec types -│ │ └── aws-targets.ts # AWS deployment target types -│ └── cdk/ # AWS CDK project for deployment -└── app/ # Application code (if agents were created) +│ │ ├── aws-targets.ts # AWS deployment target types +│ │ └── mcp.ts # Gateway and MCP tool types +│ └── cdk/ # AWS CDK project (@aws/agentcore-cdk L3 constructs) +├── app/ # Agent application code +└── evaluators/ # Custom evaluator code (if any) \`\`\` ## Schema Reference The \`agentcore/.llm-context/\` directory contains TypeScript type definitions optimized for AI coding assistants. Each -file maps to a JSON config file and includes validation constraints as comments. +file maps to a JSON config file and includes validation constraints as comments (\`@regex\`, \`@min\`, \`@max\`). -| JSON Config | Schema File | Root Type | -| ---------------------------- | --------------------------------------- | ----------------------- | -| \`agentcore/agentcore.json\` | \`agentcore/.llm-context/agentcore.ts\` | \`AgentCoreProjectSpec\` | -| \`agentcore/aws-targets.json\` | \`agentcore/.llm-context/aws-targets.ts\` | \`AWSDeploymentTarget[]\` | +| JSON Config | Schema File | Root Type | +| --- | --- | --- | +| \`agentcore/agentcore.json\` | \`agentcore/.llm-context/agentcore.ts\` | \`AgentCoreProjectSpec\` | +| \`agentcore/agentcore.json\` (gateways) | \`agentcore/.llm-context/mcp.ts\` | \`AgentCoreMcpSpec\` | +| \`agentcore/aws-targets.json\` | \`agentcore/.llm-context/aws-targets.ts\` | \`AwsDeploymentTarget[]\` | ### Key Types -- **AgentCoreProjectSpec**: Root project configuration with \`agents\`, \`memories\`, \`credentials\` arrays -- **AgentEnvSpec**: Agent configuration (runtime, entrypoint, code location) -- **Memory**: Memory resource with strategies and expiry -- **Credential**: API key credential provider +- **AgentCoreProjectSpec**: Root config with \`runtimes\`, \`memories\`, \`credentials\`, \`agentCoreGateways\`, \`evaluators\`, \`onlineEvalConfigs\`, \`policyEngines\` arrays +- **AgentEnvSpec**: Agent configuration (build type, entrypoint, code location, runtime version, network mode) +- **Memory**: Memory resource with strategies (SEMANTIC, SUMMARIZATION, USER_PREFERENCE, EPISODIC) and expiry +- **Credential**: API key or OAuth credential provider +- **AgentCoreGateway**: MCP gateway with targets (Lambda, MCP server, OpenAPI, Smithy, API Gateway) +- **Evaluator**: LLM-as-a-Judge or code-based evaluator +- **OnlineEvalConfig**: Continuous evaluation pipeline bound to an agent ### Common Enum Values - **BuildType**: \`'CodeZip'\` | \`'Container'\` -- **NetworkMode**: \`'PUBLIC'\` -- **RuntimeVersion**: \`'PYTHON_3_10'\` | \`'PYTHON_3_11'\` | \`'PYTHON_3_12'\` | \`'PYTHON_3_13'\` +- **NetworkMode**: \`'PUBLIC'\` | \`'VPC'\` +- **RuntimeVersion**: \`'PYTHON_3_10'\` | \`'PYTHON_3_11'\` | \`'PYTHON_3_12'\` | \`'PYTHON_3_13'\` | \`'PYTHON_3_14'\` | \`'NODE_18'\` | \`'NODE_20'\` | \`'NODE_22'\` - **MemoryStrategyType**: \`'SEMANTIC'\` | \`'SUMMARIZATION'\` | \`'USER_PREFERENCE'\` | \`'EPISODIC'\` +- **GatewayTargetType**: \`'lambda'\` | \`'mcpServer'\` | \`'openApiSchema'\` | \`'smithyModel'\` | \`'apiGateway'\` | \`'lambdaFunctionArn'\` +- **ModelProvider**: \`'Bedrock'\` | \`'Gemini'\` | \`'OpenAI'\` | \`'Anthropic'\` ### Build Types -- **CodeZip**: Python source is packaged as a zip artifact and deployed directly to AgentCore Runtime. -- **Container**: Agent code is built as a Docker container image. Requires a \`Dockerfile\` in the agent's \`codeLocation\` - directory. At deploy time, the source is uploaded to S3, built in CodeBuild (ARM64), pushed to a per-agent ECR - repository, and the container URI is provided to the AgentCore Runtime. For local development (\`agentcore dev\`), the - container is built and run locally with volume-mounted hot-reload. +- **CodeZip**: Python source packaged as a zip and deployed directly to AgentCore Runtime. +- **Container**: Docker image built in CodeBuild (ARM64), pushed to a per-agent ECR repository. Requires a \`Dockerfile\` + in the agent's \`codeLocation\` directory. For local development (\`agentcore dev\`), the container is built and run + locally with volume-mounted hot-reload. ### Supported Frameworks (for template agents) -- **Strands** - Works with Bedrock, Anthropic, OpenAI, Gemini -- **LangChain_LangGraph** - Works with Bedrock, Anthropic, OpenAI, Gemini -- **GoogleADK** - Gemini only -- **OpenAIAgents** - OpenAI only +- **Strands** — Bedrock, Anthropic, OpenAI, Gemini +- **LangChain/LangGraph** — Bedrock, Anthropic, OpenAI, Gemini +- **GoogleADK** — Gemini +- **OpenAI Agents** — OpenAI +- **Autogen** — Bedrock, Anthropic, OpenAI, Gemini +### Protocols -### Specific Context - -Directory pathing to local projects is required for runtimes. Both CodeZip (Python zip) and Container (Docker image) -deployment options are available. +- **HTTP** — Standard HTTP agent endpoint +- **MCP** — Model Context Protocol server +- **A2A** — Agent-to-Agent protocol (Google A2A) ## Deployment -The \`agentcore/cdk/\` subdirectory contains an AWS CDK node project. +Deployments are orchestrated through the CLI: -Deployments of this project are primarily intended to be orchestrated through the \`agentcore deploy\` command in the CLI. +\`\`\`bash +agentcore deploy # Synthesizes CDK and deploys to AWS +agentcore status # Shows deployment status +\`\`\` -Alternatively, the project can be deployed directly as a traditional CDK project: +Alternatively, deploy directly via CDK: \`\`\`bash cd agentcore/cdk npm install -npx cdk synth # Preview CloudFormation template -npx cdk deploy # Deploy to AWS +npx cdk synth +npx cdk deploy \`\`\` ## Editing Schemas @@ -4052,7 +5481,25 @@ When modifying JSON config files: 2. Check validation constraint comments (\`@regex\`, \`@min\`, \`@max\`) 3. Use exact enum values as string literals 4. Use CloudFormation-safe names (alphanumeric, start with letter) -5. Run \`agentcore validate\` command to verify changes. +5. Run \`agentcore validate\` to verify changes + +## CLI Commands + +| Command | Description | +| --- | --- | +| \`agentcore create\` | Create a new project | +| \`agentcore add \` | Add agent, memory, credential, gateway, evaluator, policy | +| \`agentcore remove \` | Remove a resource | +| \`agentcore dev\` | Run agent locally with hot-reload | +| \`agentcore deploy\` | Deploy to AWS | +| \`agentcore status\` | Show deployment status | +| \`agentcore invoke\` | Invoke agent (local or deployed) | +| \`agentcore logs\` | View agent logs | +| \`agentcore traces\` | View agent traces | +| \`agentcore eval\` | Run evaluations against an agent | +| \`agentcore package\` | Package agent artifacts | +| \`agentcore validate\` | Validate configuration | +| \`agentcore pause\` / \`resume\` | Pause or resume a deployed agent | " `; diff --git a/src/assets/__tests__/__snapshots__/dockerfile-render.test.ts.snap b/src/assets/__tests__/__snapshots__/dockerfile-render.test.ts.snap new file mode 100644 index 000000000..bd8eb132f --- /dev/null +++ b/src/assets/__tests__/__snapshots__/dockerfile-render.test.ts.snap @@ -0,0 +1,77 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Dockerfile enableOtel rendering > renders opentelemetry-instrument CMD when enableOtel is true > Dockerfile-enableOtel-true 1`] = ` +"FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +ARG UV_DEFAULT_INDEX +ARG UV_INDEX + +WORKDIR /app + +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 + +# AgentCore Runtime service contract ports +# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-service-contract.html +# 8080: HTTP Mode +# 8000: MCP Mode +# 9000: A2A Mode +EXPOSE 8080 8000 9000 + +CMD ["opentelemetry-instrument", "python", "-m", "main"] +" +`; + +exports[`Dockerfile enableOtel rendering > renders plain python CMD when enableOtel is false > Dockerfile-enableOtel-false 1`] = ` +"FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +ARG UV_DEFAULT_INDEX +ARG UV_INDEX + +WORKDIR /app + +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 + +# AgentCore Runtime service contract ports +# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-service-contract.html +# 8080: HTTP Mode +# 8000: MCP Mode +# 9000: A2A Mode +EXPOSE 8080 8000 9000 + +CMD ["python", "-m", "main"] +" +`; diff --git a/src/assets/__tests__/dockerfile-render.test.ts b/src/assets/__tests__/dockerfile-render.test.ts new file mode 100644 index 000000000..33e3968d7 --- /dev/null +++ b/src/assets/__tests__/dockerfile-render.test.ts @@ -0,0 +1,24 @@ +import * as fs from 'fs'; +import Handlebars from 'handlebars'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +const DOCKERFILE_PATH = path.resolve(__dirname, '..', 'container', 'python', 'Dockerfile'); + +describe('Dockerfile enableOtel rendering', () => { + const template = Handlebars.compile(fs.readFileSync(DOCKERFILE_PATH, 'utf-8')); + + it('renders opentelemetry-instrument CMD when enableOtel is true', () => { + const rendered = template({ entrypoint: 'main', enableOtel: true }); + expect(rendered).toMatchSnapshot('Dockerfile-enableOtel-true'); + expect(rendered).toContain('opentelemetry-instrument'); + expect(rendered).not.toContain('CMD ["python", "-m"'); + }); + + it('renders plain python CMD when enableOtel is false', () => { + const rendered = template({ entrypoint: 'main', enableOtel: false }); + expect(rendered).toMatchSnapshot('Dockerfile-enableOtel-false'); + expect(rendered).toContain('CMD ["python", "-m"'); + expect(rendered).not.toContain('opentelemetry-instrument'); + }); +}); diff --git a/src/assets/agents/AGENTS.md b/src/assets/agents/AGENTS.md index 7861af7ae..2153de014 100644 --- a/src/assets/agents/AGENTS.md +++ b/src/assets/agents/AGENTS.md @@ -2,103 +2,114 @@ This project contains configuration and infrastructure for an Amazon Bedrock AgentCore application. -The `agentcore/` directory serves as a declarative model of an AgentCore project along with a concrete implementation -through the `agentcore/cdk/` project which is modeled to take the configs as input. The project uses a **flat resource -model** where agents, memories, and credentials are top-level arrays. +The `agentcore/` directory is a declarative model of the project. The `agentcore/cdk/` subdirectory uses the +`@aws/agentcore-cdk` L3 constructs to deploy the configuration to AWS. ## Mental Model -The project uses a **flat resource model**. Agents, memories, and credentials are independent top-level arrays in -`agentcore.json`. There is no binding or attachment between resources in the schema — each resource is provisioned -independently. To use a memory or credential from an agent, the application code discovers the resource at runtime -(e.g., via environment variables or SDK calls). Tags defined in `agentcore.json` flow through to deployed CloudFormation resources. +The project uses a **flat resource model**. Agents, memories, credentials, gateways, evaluators, and policies are +independent top-level arrays in `agentcore.json`. There is no binding between resources in the schema — each resource is +provisioned independently. Agents discover memories and credentials at runtime via environment variables or SDK calls. +Tags defined in `agentcore.json` flow through to deployed CloudFormation resources. ## Critical Invariants -1. **Schema-First Authority:** The `.json` files are the absolute source of truth. Do not attempt to modify agent - behavior by editing the generated CDK code in `cdk/`. -2. **Resource Identity:** The `name` field in the schema determines the CloudFormation Logical ID. - - **Renaming** an agent or target will **destroy and recreate** that resource. - - **Modifying** other fields (descriptions, config) will update the resource **in-place**. -3. **1:1 Validation:** The schema maps directly to valid CloudFormation. If your JSON conforms to the types in - `.llm-context/`, it will deploy successfully. -4. **Resource Removal:** To remove all resources, use `agentcore remove all`. To tear down deployed infrastructure, run - `agentcore deploy` after removal — it will detect the empty state and offer a teardown flow. +1. **Schema-First Authority:** The `.json` files are the source of truth. Do not modify agent behavior by editing + generated CDK code in `cdk/`. +2. **Resource Identity:** The `name` field determines the CloudFormation Logical ID. + - **Renaming** a resource will **destroy and recreate** it. + - **Modifying** other fields will update the resource **in-place**. +3. **Schema Validation:** If your JSON conforms to the types in `.llm-context/`, it will deploy successfully. Run + `agentcore validate` to check. +4. **Resource Removal:** Use `agentcore remove` to remove resources. Run `agentcore deploy` after removal to tear down + deployed infrastructure. ## Directory Structure ``` -myNewProject/ -├── AGENTS.md # This file - AI coding assistant context -├── agentcore/ # AgentCore configuration directory +myProject/ +├── AGENTS.md # This file — AI coding assistant context +├── agentcore/ │ ├── agentcore.json # Main project config (AgentCoreProjectSpec) -│ ├── aws-targets.json # Deployment targets -│ ├── .llm-context/ # TypeScript type definitions for AI coding assistants -│ │ ├── README.md # Guide to using the schema files +│ ├── aws-targets.json # Deployment targets (account + region) +│ ├── .env.local # Secrets — API keys (gitignored) +│ ├── .llm-context/ # TypeScript type definitions for AI assistants +│ │ ├── README.md # Guide to using schema files │ │ ├── agentcore.ts # AgentCoreProjectSpec types -│ │ └── aws-targets.ts # AWS deployment target types -│ └── cdk/ # AWS CDK project for deployment -└── app/ # Application code (if agents were created) +│ │ ├── aws-targets.ts # AWS deployment target types +│ │ └── mcp.ts # Gateway and MCP tool types +│ └── cdk/ # AWS CDK project (@aws/agentcore-cdk L3 constructs) +├── app/ # Agent application code +└── evaluators/ # Custom evaluator code (if any) ``` ## Schema Reference The `agentcore/.llm-context/` directory contains TypeScript type definitions optimized for AI coding assistants. Each -file maps to a JSON config file and includes validation constraints as comments. +file maps to a JSON config file and includes validation constraints as comments (`@regex`, `@min`, `@max`). -| JSON Config | Schema File | Root Type | -| ---------------------------- | --------------------------------------- | ----------------------- | -| `agentcore/agentcore.json` | `agentcore/.llm-context/agentcore.ts` | `AgentCoreProjectSpec` | -| `agentcore/aws-targets.json` | `agentcore/.llm-context/aws-targets.ts` | `AWSDeploymentTarget[]` | +| JSON Config | Schema File | Root Type | +| --- | --- | --- | +| `agentcore/agentcore.json` | `agentcore/.llm-context/agentcore.ts` | `AgentCoreProjectSpec` | +| `agentcore/agentcore.json` (gateways) | `agentcore/.llm-context/mcp.ts` | `AgentCoreMcpSpec` | +| `agentcore/aws-targets.json` | `agentcore/.llm-context/aws-targets.ts` | `AwsDeploymentTarget[]` | ### Key Types -- **AgentCoreProjectSpec**: Root project configuration with `agents`, `memories`, `credentials` arrays -- **AgentEnvSpec**: Agent configuration (runtime, entrypoint, code location) -- **Memory**: Memory resource with strategies and expiry -- **Credential**: API key credential provider +- **AgentCoreProjectSpec**: Root config with `runtimes`, `memories`, `credentials`, `agentCoreGateways`, `evaluators`, `onlineEvalConfigs`, `policyEngines` arrays +- **AgentEnvSpec**: Agent configuration (build type, entrypoint, code location, runtime version, network mode) +- **Memory**: Memory resource with strategies (SEMANTIC, SUMMARIZATION, USER_PREFERENCE, EPISODIC) and expiry +- **Credential**: API key or OAuth credential provider +- **AgentCoreGateway**: MCP gateway with targets (Lambda, MCP server, OpenAPI, Smithy, API Gateway) +- **Evaluator**: LLM-as-a-Judge or code-based evaluator +- **OnlineEvalConfig**: Continuous evaluation pipeline bound to an agent ### Common Enum Values - **BuildType**: `'CodeZip'` | `'Container'` -- **NetworkMode**: `'PUBLIC'` -- **RuntimeVersion**: `'PYTHON_3_10'` | `'PYTHON_3_11'` | `'PYTHON_3_12'` | `'PYTHON_3_13'` +- **NetworkMode**: `'PUBLIC'` | `'VPC'` +- **RuntimeVersion**: `'PYTHON_3_10'` | `'PYTHON_3_11'` | `'PYTHON_3_12'` | `'PYTHON_3_13'` | `'PYTHON_3_14'` | `'NODE_18'` | `'NODE_20'` | `'NODE_22'` - **MemoryStrategyType**: `'SEMANTIC'` | `'SUMMARIZATION'` | `'USER_PREFERENCE'` | `'EPISODIC'` +- **GatewayTargetType**: `'lambda'` | `'mcpServer'` | `'openApiSchema'` | `'smithyModel'` | `'apiGateway'` | `'lambdaFunctionArn'` +- **ModelProvider**: `'Bedrock'` | `'Gemini'` | `'OpenAI'` | `'Anthropic'` ### Build Types -- **CodeZip**: Python source is packaged as a zip artifact and deployed directly to AgentCore Runtime. -- **Container**: Agent code is built as a Docker container image. Requires a `Dockerfile` in the agent's `codeLocation` - directory. At deploy time, the source is uploaded to S3, built in CodeBuild (ARM64), pushed to a per-agent ECR - repository, and the container URI is provided to the AgentCore Runtime. For local development (`agentcore dev`), the - container is built and run locally with volume-mounted hot-reload. +- **CodeZip**: Python source packaged as a zip and deployed directly to AgentCore Runtime. +- **Container**: Docker image built in CodeBuild (ARM64), pushed to a per-agent ECR repository. Requires a `Dockerfile` + in the agent's `codeLocation` directory. For local development (`agentcore dev`), the container is built and run + locally with volume-mounted hot-reload. ### Supported Frameworks (for template agents) -- **Strands** - Works with Bedrock, Anthropic, OpenAI, Gemini -- **LangChain_LangGraph** - Works with Bedrock, Anthropic, OpenAI, Gemini -- **GoogleADK** - Gemini only -- **OpenAIAgents** - OpenAI only +- **Strands** — Bedrock, Anthropic, OpenAI, Gemini +- **LangChain/LangGraph** — Bedrock, Anthropic, OpenAI, Gemini +- **GoogleADK** — Gemini +- **OpenAI Agents** — OpenAI +- **Autogen** — Bedrock, Anthropic, OpenAI, Gemini +### Protocols -### Specific Context - -Directory pathing to local projects is required for runtimes. Both CodeZip (Python zip) and Container (Docker image) -deployment options are available. +- **HTTP** — Standard HTTP agent endpoint +- **MCP** — Model Context Protocol server +- **A2A** — Agent-to-Agent protocol (Google A2A) ## Deployment -The `agentcore/cdk/` subdirectory contains an AWS CDK node project. +Deployments are orchestrated through the CLI: -Deployments of this project are primarily intended to be orchestrated through the `agentcore deploy` command in the CLI. +```bash +agentcore deploy # Synthesizes CDK and deploys to AWS +agentcore status # Shows deployment status +``` -Alternatively, the project can be deployed directly as a traditional CDK project: +Alternatively, deploy directly via CDK: ```bash cd agentcore/cdk npm install -npx cdk synth # Preview CloudFormation template -npx cdk deploy # Deploy to AWS +npx cdk synth +npx cdk deploy ``` ## Editing Schemas @@ -109,4 +120,22 @@ When modifying JSON config files: 2. Check validation constraint comments (`@regex`, `@min`, `@max`) 3. Use exact enum values as string literals 4. Use CloudFormation-safe names (alphanumeric, start with letter) -5. Run `agentcore validate` command to verify changes. +5. Run `agentcore validate` to verify changes + +## CLI Commands + +| Command | Description | +| --- | --- | +| `agentcore create` | Create a new project | +| `agentcore add ` | Add agent, memory, credential, gateway, evaluator, policy | +| `agentcore remove ` | Remove a resource | +| `agentcore dev` | Run agent locally with hot-reload | +| `agentcore deploy` | Deploy to AWS | +| `agentcore status` | Show deployment status | +| `agentcore invoke` | Invoke agent (local or deployed) | +| `agentcore logs` | View agent logs | +| `agentcore traces` | View agent traces | +| `agentcore eval` | Run evaluations against an agent | +| `agentcore package` | Package agent artifacts | +| `agentcore validate` | Validate configuration | +| `agentcore pause` / `resume` | Pause or resume a deployed agent | diff --git a/src/assets/cdk/cdk.json b/src/assets/cdk/cdk.json index 40f5c4544..19e6983ab 100644 --- a/src/assets/cdk/cdk.json +++ b/src/assets/cdk/cdk.json @@ -9,7 +9,7 @@ "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, "@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/core:checkSecretUsage": true, - "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk/core:target-partitions": ["aws", "aws-cn", "aws-us-gov"], "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 5564490b9..aa58892c2 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -23,7 +23,7 @@ "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "0.1.0-alpha.17", + "@aws/agentcore-cdk": "^0.1.0-alpha.19", "aws-cdk-lib": "^2.248.0", "constructs": "^10.0.0" } diff --git a/src/assets/container/python/Dockerfile b/src/assets/container/python/Dockerfile index a6c55db90..ec82f36fa 100644 --- a/src/assets/container/python/Dockerfile +++ b/src/assets/container/python/Dockerfile @@ -31,4 +31,8 @@ USER bedrock_agentcore # 9000: A2A Mode EXPOSE 8080 8000 9000 +{{#if enableOtel}} CMD ["opentelemetry-instrument", "python", "-m", "{{entrypoint}}"] +{{else}} +CMD ["python", "-m", "{{entrypoint}}"] +{{/if}} diff --git a/src/assets/evaluators/python-lambda/execution-role-policy.json b/src/assets/evaluators/python-lambda/execution-role-policy.json index 6a49ca7d9..b3b98be42 100644 --- a/src/assets/evaluators/python-lambda/execution-role-policy.json +++ b/src/assets/evaluators/python-lambda/execution-role-policy.json @@ -4,7 +4,7 @@ { "Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], - "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/*" + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" } ] } diff --git a/src/assets/python/a2a/googleadk/base/main.py b/src/assets/python/a2a/googleadk/base/main.py index f53f57045..f918696af 100644 --- a/src/assets/python/a2a/googleadk/base/main.py +++ b/src/assets/python/a2a/googleadk/base/main.py @@ -1,3 +1,4 @@ +import os from google.adk.agents import Agent from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor from google.adk.runners import Runner @@ -6,18 +7,79 @@ from bedrock_agentcore.runtime import serve_a2a from model.load import load_model +load_model() # Sets GOOGLE_API_KEY env var (returns None) + def add_numbers(a: int, b: int) -> int: """Return the sum of two numbers.""" return a + b +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + +AGENT_INSTRUCTION = """ +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}} +""" + agent = Agent( - model=load_model(), + model="gemini-2.5-flash", name="{{ name }}", description="A helpful assistant that can use tools.", - instruction="You are a helpful assistant. Use tools when appropriate.", - tools=[add_numbers], + instruction=AGENT_INSTRUCTION, + tools=tools, ) runner = Runner( diff --git a/src/assets/python/a2a/langchain_langgraph/base/main.py b/src/assets/python/a2a/langchain_langgraph/base/main.py index 0412d50ce..9147c53db 100644 --- a/src/assets/python/a2a/langchain_langgraph/base/main.py +++ b/src/assets/python/a2a/langchain_langgraph/base/main.py @@ -1,5 +1,7 @@ +import os from langchain_core.tools import tool from langgraph.prebuilt import create_react_agent +from opentelemetry.instrumentation.langchain import LangchainInstrumentor from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater @@ -8,6 +10,8 @@ from bedrock_agentcore.runtime import serve_a2a from model.load import load_model +LangchainInstrumentor().instrument() + @tool def add_numbers(a: int, b: int) -> int: @@ -15,8 +19,70 @@ def add_numbers(a: int, b: int) -> int: return a + b +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +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}} +""" + model = load_model() -graph = create_react_agent(model, tools=[add_numbers]) +graph = create_react_agent(model, tools=tools, prompt=SYSTEM_PROMPT) class LangGraphA2AExecutor(AgentExecutor): diff --git a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml index ab9b3cbec..ef0715758 100644 --- a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ {{/if}}{{#if (eq modelProvider "Gemini")}}"langchain-google-genai >= 2.0.0", {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", {{/if}}"aws-opentelemetry-distro", + "opentelemetry-instrumentation-langchain >= 0.59.0", "bedrock-agentcore[a2a] >= 1.0.3", "botocore[crt] >= 1.35.0", "langgraph >= 0.2.0", diff --git a/src/assets/python/a2a/strands/base/main.py b/src/assets/python/a2a/strands/base/main.py index 0e7a9f921..a6f09a69c 100644 --- a/src/assets/python/a2a/strands/base/main.py +++ b/src/assets/python/a2a/strands/base/main.py @@ -5,6 +5,9 @@ {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} +{{#if sessionStorageMountPath}} +import os +{{/if}} @tool @@ -15,6 +18,66 @@ def add_numbers(a: int, b: int) -> int: tools = [add_numbers] +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +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 hasMemory}} def agent_factory(): cache = {} @@ -24,7 +87,7 @@ 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="You are a helpful assistant. Use tools when appropriate.", + system_prompt=SYSTEM_PROMPT, tools=tools, ) return cache[key] @@ -35,7 +98,7 @@ def get_or_create_agent(session_id, user_id): {{else}} agent = Agent( model=load_model(), - system_prompt="You are a helpful assistant. Use tools when appropriate.", + system_prompt=SYSTEM_PROMPT, tools=tools, ) {{/if}} 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/googleadk/base/README.md b/src/assets/python/agui/googleadk/base/README.md new file mode 100644 index 000000000..5258d4b9a --- /dev/null +++ b/src/assets/python/agui/googleadk/base/README.md @@ -0,0 +1,30 @@ +# {{ name }} + +An AG-UI agent deployed on Amazon Bedrock AgentCore using Google ADK. + +## Overview + +This agent implements the AG-UI protocol using Google's Agent Development Kit, enabling rich agent-user interaction via the AG-UI event stream. + +## Local Development + +```bash +uv sync +uv run python main.py +``` + +The agent starts on port 8080 and serves requests at `/invocations`. + +## Health Check + +``` +GET /ping +``` + +Returns `{"status": "healthy"}`. + +## Deploy + +```bash +agentcore deploy +``` diff --git a/src/assets/python/agui/googleadk/base/gitignore.template b/src/assets/python/agui/googleadk/base/gitignore.template new file mode 100644 index 000000000..fa1c60aea --- /dev/null +++ b/src/assets/python/agui/googleadk/base/gitignore.template @@ -0,0 +1,41 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/python/agui/googleadk/base/main.py b/src/assets/python/agui/googleadk/base/main.py new file mode 100644 index 000000000..0585bb58e --- /dev/null +++ b/src/assets/python/agui/googleadk/base/main.py @@ -0,0 +1,31 @@ +import os +import uvicorn +from google.adk.agents import LlmAgent +from ag_ui_adk import ADKAgent, AGUIToolset, create_adk_app +from model.load import load_model + +load_model() + +agent = LlmAgent( + name="{{ name }}", + model="gemini-2.5-flash", + instruction="You are a helpful assistant.", + tools=[AGUIToolset()], +) + +adk_agent = ADKAgent( + adk_agent=agent, + app_name="{{ name }}", + use_in_memory_services=True, +) + +app = create_adk_app(adk_agent, path="/invocations") + + +@app.get("/ping") +async def ping(): + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) diff --git a/src/assets/python/agui/googleadk/base/model/__init__.py b/src/assets/python/agui/googleadk/base/model/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/python/agui/googleadk/base/model/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/python/agui/googleadk/base/model/load.py b/src/assets/python/agui/googleadk/base/model/load.py new file mode 100644 index 000000000..7c74e1ce2 --- /dev/null +++ b/src/assets/python/agui/googleadk/base/model/load.py @@ -0,0 +1,41 @@ +import os +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> None: + """ + Set up Gemini API key authentication. + Uses AgentCore Identity for API key management in deployed environments, + and falls back to .env file for local development. + Sets the GOOGLE_API_KEY environment variable for the Google ADK. + """ + api_key = _get_api_key() + # Use Google AI Studios API Key Authentication. + # https://google.github.io/adk-docs/agents/models/#google-ai-studio + os.environ["GOOGLE_API_KEY"] = api_key + # Set to TRUE is using Google Vertex AI, Set to FALSE for Google AI Studio + os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "FALSE" diff --git a/src/assets/python/agui/googleadk/base/pyproject.toml b/src/assets/python/agui/googleadk/base/pyproject.toml new file mode 100644 index 000000000..0b3a3fed7 --- /dev/null +++ b/src/assets/python/agui/googleadk/base/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore AG-UI Agent using Google ADK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "ag-ui-adk >= 0.6.0", + "ag-ui-protocol >= 0.1.10", + "bedrock-agentcore >= 1.0.3", + "fastapi >= 0.115.12", + "google-adk >= 1.16.0", + "google-genai >= 1.0.0", + "opentelemetry-distro", + "opentelemetry-exporter-otlp", + "uvicorn >= 0.34.3", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/python/agui/langchain_langgraph/base/README.md b/src/assets/python/agui/langchain_langgraph/base/README.md new file mode 100644 index 000000000..d0b2793b3 --- /dev/null +++ b/src/assets/python/agui/langchain_langgraph/base/README.md @@ -0,0 +1,22 @@ +# {{ name }} + +An AG-UI agent deployed on Amazon Bedrock AgentCore using LangChain + LangGraph. + +## Overview + +This agent implements the AG-UI protocol using LangGraph, enabling seamless frontend-to-agent communication with support for streaming, tool calls, and frontend-injected tools. + +## Local Development + +```bash +uv sync +uv run python main.py +``` + +The agent starts on port 8080. + +## Deploy + +```bash +agentcore deploy +``` diff --git a/src/assets/python/agui/langchain_langgraph/base/gitignore.template b/src/assets/python/agui/langchain_langgraph/base/gitignore.template new file mode 100644 index 000000000..fa1c60aea --- /dev/null +++ b/src/assets/python/agui/langchain_langgraph/base/gitignore.template @@ -0,0 +1,41 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/python/agui/langchain_langgraph/base/main.py b/src/assets/python/agui/langchain_langgraph/base/main.py new file mode 100644 index 000000000..350e9ce4b --- /dev/null +++ b/src/assets/python/agui/langchain_langgraph/base/main.py @@ -0,0 +1,74 @@ +import os + +os.environ["LANGGRAPH_FAST_API"] = "true" + +import uvicorn +from typing import Any, List +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from langgraph.graph import StateGraph, START +from langgraph.graph.message import MessagesState +from langgraph.checkpoint.memory import MemorySaver +from langgraph.prebuilt import ToolNode, tools_condition +from langchain_core.tools import tool +from opentelemetry.instrumentation.langchain import LangchainInstrumentor +from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint +from model.load import load_model + +LangchainInstrumentor().instrument() + + +@tool +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers.""" + return a + b + + +backend_tools = [add_numbers] +model = load_model() + + +class AgentState(MessagesState): + tools: List[Any] + + +def chat_node(state: AgentState): + bound_model = model.bind_tools( + [*state.get("tools", []), *backend_tools], + ) + response = bound_model.invoke(state["messages"]) + return {"messages": [response]} + + +builder = StateGraph(AgentState) +builder.add_node("chat", chat_node) +builder.add_node("tools", ToolNode(tools=backend_tools)) +builder.add_edge(START, "chat") +builder.add_conditional_edges("chat", tools_condition) +builder.add_edge("tools", "chat") +graph = builder.compile(checkpointer=MemorySaver()) + +agent = LangGraphAgent( + name="{{ name }}", + graph=graph, + description="A helpful assistant", +) + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +add_langgraph_fastapi_endpoint(app=app, agent=agent, path="/invocations") + + +@app.get("/ping") +async def ping(): + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) diff --git a/src/assets/python/agui/langchain_langgraph/base/model/__init__.py b/src/assets/python/agui/langchain_langgraph/base/model/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/python/agui/langchain_langgraph/base/model/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/python/agui/langchain_langgraph/base/model/load.py b/src/assets/python/agui/langchain_langgraph/base/model/load.py new file mode 100644 index 000000000..b8f2d71ea --- /dev/null +++ b/src/assets/python/agui/langchain_langgraph/base/model/load.py @@ -0,0 +1,123 @@ +{{#if (eq modelProvider "Bedrock")}} +from langchain_aws import ChatBedrock + +# Uses global inference profile for Claude Sonnet 4.5 +# https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html +MODEL_ID = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + + +def load_model() -> ChatBedrock: + """Get Bedrock model client using IAM credentials.""" + return ChatBedrock(model_id=MODEL_ID) +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import os +from langchain_anthropic import ChatAnthropic +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> ChatAnthropic: + """Get authenticated Anthropic model client.""" + return ChatAnthropic( + model="claude-sonnet-4-5-20250929", + api_key=_get_api_key() + ) +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import os +from langchain_openai import ChatOpenAI +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> ChatOpenAI: + """Get authenticated OpenAI model client.""" + return ChatOpenAI( + model="gpt-4.1", + api_key=_get_api_key() + ) +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import os +from langchain_google_genai import ChatGoogleGenerativeAI +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> ChatGoogleGenerativeAI: + """Get authenticated Gemini model client.""" + return ChatGoogleGenerativeAI( + model="gemini-2.5-flash", + api_key=_get_api_key() + ) +{{/if}} diff --git a/src/assets/python/agui/langchain_langgraph/base/pyproject.toml b/src/assets/python/agui/langchain_langgraph/base/pyproject.toml new file mode 100644 index 000000000..6e0e59de7 --- /dev/null +++ b/src/assets/python/agui/langchain_langgraph/base/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore AG-UI Agent using LangChain + LangGraph" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "ag-ui-langgraph >= 0.0.31", + "ag-ui-protocol >= 0.1.10", + {{#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", + {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", + {{/if}}"aws-opentelemetry-distro", + "opentelemetry-instrumentation-langchain >= 0.59.0", + "bedrock-agentcore >= 1.0.3", + "botocore[crt] >= 1.35.0", + "langgraph >= 0.3.25", + "langchain >= 0.3.0", + "langchain-core >= 0.3.0", + "fastapi >= 0.115.12", + "uvicorn >= 0.34.3", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/python/agui/strands/base/README.md b/src/assets/python/agui/strands/base/README.md new file mode 100644 index 000000000..57a8cda94 --- /dev/null +++ b/src/assets/python/agui/strands/base/README.md @@ -0,0 +1,22 @@ +# {{ name }} + +An AG-UI agent deployed on Amazon Bedrock AgentCore using Strands SDK. + +## Overview + +This agent implements the AG-UI protocol, enabling streaming agent-to-UI communication. The agent exposes an `/invocations` endpoint that accepts AG-UI protocol requests and streams responses back to the client. + +## Local Development + +```bash +uv sync +uv run python main.py +``` + +The agent starts on port 8080. + +## Deploy + +```bash +agentcore deploy +``` diff --git a/src/assets/python/agui/strands/base/gitignore.template b/src/assets/python/agui/strands/base/gitignore.template new file mode 100644 index 000000000..fa1c60aea --- /dev/null +++ b/src/assets/python/agui/strands/base/gitignore.template @@ -0,0 +1,41 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/src/assets/python/agui/strands/base/main.py b/src/assets/python/agui/strands/base/main.py new file mode 100644 index 000000000..e3bd3e652 --- /dev/null +++ b/src/assets/python/agui/strands/base/main.py @@ -0,0 +1,43 @@ +import os + +# Suppress OpenTelemetry warnings during local development; remove for production +if os.getenv("LOCAL_DEV") == "1": + os.environ["OTEL_SDK_DISABLED"] = "true" + +import uvicorn +from strands import Agent, tool +from ag_ui_strands import StrandsAgent, StrandsAgentConfig, create_strands_app +from model.load import load_model +{{#if hasMemory}} +from memory.session import get_memory_session_manager +{{/if}} + + +@tool +def add_numbers(a: int, b: int) -> int: + """Return the sum of two numbers.""" + return a + b + + +tools = [add_numbers] + +agent = Agent( + model=load_model(), + system_prompt="You are a helpful assistant. Use tools when appropriate.", + tools=tools, +) + +{{#if hasMemory}} +def session_manager_provider(input_data): + return get_memory_session_manager(input_data.thread_id, "default-user") + +config = StrandsAgentConfig(session_manager_provider=session_manager_provider) +{{else}} +config = StrandsAgentConfig() +{{/if}} + +agui_agent = StrandsAgent(agent=agent, name="{{ name }}", description="A helpful assistant", config=config) +app = create_strands_app(agui_agent, path="/invocations", ping_path="/ping") + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) diff --git a/src/assets/python/agui/strands/base/model/__init__.py b/src/assets/python/agui/strands/base/model/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/python/agui/strands/base/model/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/python/agui/strands/base/model/load.py b/src/assets/python/agui/strands/base/model/load.py new file mode 100644 index 000000000..8954269e6 --- /dev/null +++ b/src/assets/python/agui/strands/base/model/load.py @@ -0,0 +1,123 @@ +{{#if (eq modelProvider "Bedrock")}} +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") +{{/if}} +{{#if (eq modelProvider "Anthropic")}} +import os + +from strands.models.anthropic import AnthropicModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> AnthropicModel: + """Get authenticated Anthropic model client.""" + return AnthropicModel( + client_args={"api_key": _get_api_key()}, + model_id="claude-sonnet-4-5-20250929", + max_tokens=5000, + ) +{{/if}} +{{#if (eq modelProvider "OpenAI")}} +import os + +from strands.models.openai import OpenAIModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> OpenAIModel: + """Get authenticated OpenAI model client.""" + return OpenAIModel( + client_args={"api_key": _get_api_key()}, + model_id="gpt-4.1", + ) +{{/if}} +{{#if (eq modelProvider "Gemini")}} +import os + +from strands.models.gemini import GeminiModel +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() + + +def load_model() -> GeminiModel: + """Get authenticated Gemini model client.""" + return GeminiModel( + client_args={"api_key": _get_api_key()}, + model_id="gemini-2.5-flash", + ) +{{/if}} diff --git a/src/assets/python/agui/strands/base/pyproject.toml b/src/assets/python/agui/strands/base/pyproject.toml new file mode 100644 index 000000000..12e62e3fc --- /dev/null +++ b/src/assets/python/agui/strands/base/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "AgentCore AG-UI Agent using Strands SDK" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", + {{/if}}"ag-ui-strands >= 0.1.7", + "ag-ui-protocol >= 0.1.10", + "aws-opentelemetry-distro", + "bedrock-agentcore >= 1.0.3", + "botocore[crt] >= 1.35.0", + "fastapi >= 0.115.12", + {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", + {{/if}}{{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", + {{/if}}"strands-agents >= 1.15.0", + "strands-agents-tools >= 0.2.14", + "uvicorn >= 0.34.3", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/assets/python/agui/strands/capabilities/memory/__init__.py b/src/assets/python/agui/strands/capabilities/memory/__init__.py new file mode 100644 index 000000000..0e632e10c --- /dev/null +++ b/src/assets/python/agui/strands/capabilities/memory/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/src/assets/python/agui/strands/capabilities/memory/session.py b/src/assets/python/agui/strands/capabilities/memory/session.py new file mode 100644 index 000000000..46883bf57 --- /dev/null +++ b/src/assets/python/agui/strands/capabilities/memory/session.py @@ -0,0 +1,43 @@ +import os +import uuid +from typing import Optional + +from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryProviders.[0].strategies.length}}, RetrievalConfig{{/if}} +from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager + +MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") +REGION = os.getenv("AWS_REGION") + +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")}} + f"/users/{actor_id}/facts": RetrievalConfig(top_k=3, relevance_score=0.5), +{{/if}} +{{#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 "SUMMARIZATION")}} + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), +{{/if}} + } +{{/if}} + + return AgentCoreMemorySessionManager( + AgentCoreMemoryConfig( + memory_id=MEMORY_ID, + session_id=session_id, + actor_id=actor_id, +{{#if memoryProviders.[0].strategies.length}} + retrieval_config=retrieval_config, +{{/if}} + ), + REGION + ) diff --git a/src/assets/python/http/autogen/base/main.py b/src/assets/python/http/autogen/base/main.py index 43789e637..028309e22 100644 --- a/src/assets/python/http/autogen/base/main.py +++ b/src/assets/python/http/autogen/base/main.py @@ -22,6 +22,66 @@ def add_numbers(a: int, b: int) -> int: # Define a collection of tools used by the model tools = [add_numbers_tool] +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([ + FunctionTool(file_read, description="Read a file from persistent storage. The path is relative to the storage root."), + FunctionTool(file_write, description="Write content to a file in persistent storage. The path is relative to the storage root."), + FunctionTool(list_files, description="List files in persistent storage. The directory is relative to the storage root."), +]) +{{/if}} + +SYSTEM_MESSAGE = """ +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}} +""" @app.entrypoint async def invoke(payload, context): @@ -35,7 +95,7 @@ async def invoke(payload, context): name="{{ name }}", model_client=load_model(), tools=tools + mcp_tools, - system_message="You are a helpful assistant. Use tools when appropriate.", + system_message=SYSTEM_MESSAGE, ) # Process the user prompt diff --git a/src/assets/python/http/googleadk/base/main.py b/src/assets/python/http/googleadk/base/main.py index 5ce996089..aa809b686 100644 --- a/src/assets/python/http/googleadk/base/main.py +++ b/src/assets/python/http/googleadk/base/main.py @@ -26,6 +26,66 @@ def add_numbers(a: int, b: int) -> int: return a + b +# Define a collection of tools used by the model +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + +AGENT_INSTRUCTION = """ +I can answer your questions using the knowledge I have! +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +""" + # Get MCP Toolset {{#if hasGateway}} mcp_toolset = get_all_gateway_mcp_toolsets() @@ -48,8 +108,8 @@ def ensure_credentials_loaded(): model=MODEL_ID, name="{{ name }}", description="Agent to answer questions", - instruction="I can answer your questions using the knowledge I have!", - tools=mcp_toolset + [add_numbers], + instruction=AGENT_INSTRUCTION, + tools=mcp_toolset + tools, ) diff --git a/src/assets/python/http/langchain_langgraph/base/main.py b/src/assets/python/http/langchain_langgraph/base/main.py index 949a652fa..dcb9eb13c 100644 --- a/src/assets/python/http/langchain_langgraph/base/main.py +++ b/src/assets/python/http/langchain_langgraph/base/main.py @@ -2,6 +2,7 @@ from langchain_core.messages import HumanMessage from langgraph.prebuilt import create_react_agent from langchain.tools import tool +from opentelemetry.instrumentation.langchain import LangchainInstrumentor from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} @@ -10,6 +11,8 @@ from mcp_client.client import get_streamable_http_mcp_client {{/if}} +LangchainInstrumentor().instrument() + app = BedrockAgentCoreApp() log = app.logger @@ -32,6 +35,66 @@ def add_numbers(a: int, b: int) -> int: # Define a collection of tools used by the model tools = [add_numbers] +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +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}} +""" + @app.entrypoint async def invoke(payload, context): @@ -50,7 +113,7 @@ 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) + graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=SYSTEM_PROMPT) # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") diff --git a/src/assets/python/http/langchain_langgraph/base/pyproject.toml b/src/assets/python/http/langchain_langgraph/base/pyproject.toml index ef07adf0b..ce261f6bf 100644 --- a/src/assets/python/http/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/http/langchain_langgraph/base/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", + "opentelemetry-instrumentation-langchain >= 0.59.0", "langgraph >= 1.0.2", "mcp >= 1.19.0", "langchain-mcp-adapters >= 0.1.11", diff --git a/src/assets/python/http/openaiagents/base/main.py b/src/assets/python/http/openaiagents/base/main.py index 57f497554..3fc704c19 100644 --- a/src/assets/python/http/openaiagents/base/main.py +++ b/src/assets/python/http/openaiagents/base/main.py @@ -35,6 +35,68 @@ def add_numbers(a: int, b: int) -> int: return a + b +tools = [add_numbers] + +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@function_tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@function_tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@function_tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + +INSTRUCTIONS = """ +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 the agent execution async def main(query): ensure_credentials_loaded() @@ -44,8 +106,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=mcp_servers, - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result @@ -53,8 +116,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=[], - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result @@ -65,8 +129,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=active_servers, - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result @@ -74,8 +139,9 @@ async def main(query): agent = Agent( name="{{ name }}", model="gpt-4.1", + instructions=INSTRUCTIONS, mcp_servers=[], - tools=[add_numbers] + tools=tools ) result = await Runner.run(agent, query) return result diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index 215469150..f7b69d3e4 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -9,6 +9,9 @@ {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} +{{#if sessionStorageMountPath}} +import os +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -30,11 +33,70 @@ def add_numbers(a: int, b: int) -> int: return a+b tools.append(add_numbers) +{{#if sessionStorageMountPath}} +SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}" + +def _safe_resolve(path: str) -> str: + """Resolve path safely within the storage boundary.""" + resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/"))) + if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)): + raise ValueError(f"Path '{path}' is outside the storage boundary") + return resolved + +@tool +def file_read(path: str) -> str: + """Read a file from persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + with open(full_path) as f: + return f.read() + except ValueError as e: + return str(e) + except OSError as e: + return f"Error reading '{path}': {e.strerror}" + +@tool +def file_write(path: str, content: str) -> str: + """Write content to a file in persistent storage. The path is relative to the storage root.""" + try: + full_path = _safe_resolve(path) + parent = os.path.dirname(full_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + return f"Written to {path}" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error writing '{path}': {e.strerror}" + +@tool +def list_files(directory: str = "") -> str: + """List files in persistent storage. The directory is relative to the storage root.""" + try: + target = _safe_resolve(directory) + entries = os.listdir(target) + return "\n".join(entries) if entries else "(empty directory)" + except ValueError as e: + return str(e) + except OSError as e: + return f"Error listing '{directory}': {e.strerror}" + +tools.extend([file_read, file_write, list_files]) +{{/if}} + # Add MCP client to tools if available 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}} +""" {{#if hasMemory}} def agent_factory(): @@ -46,9 +108,7 @@ 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=""" - You are a helpful assistant. Use tools when appropriate. - """, + system_prompt=SYSTEM_PROMPT, tools=tools ) return cache[key] @@ -62,9 +122,7 @@ def get_or_create_agent(): if _agent is None: _agent = Agent( model=load_model(), - system_prompt=""" - You are a helpful assistant. Use tools when appropriate. - """, + system_prompt=SYSTEM_PROMPT, tools=tools ) return _agent 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/aws/__tests__/agui-types.test.ts b/src/cli/aws/__tests__/agui-types.test.ts new file mode 100644 index 000000000..f564f1fa3 --- /dev/null +++ b/src/cli/aws/__tests__/agui-types.test.ts @@ -0,0 +1,262 @@ +import { + type AguiCustomEvent, + AguiErrorCode, + AguiEventType, + type AguiReasoningMessageContent, + type AguiRunError, + type AguiRunFinished, + type AguiRunStarted, + type AguiStateDelta, + type AguiStateSnapshot, + type AguiStepStarted, + type AguiTextMessageContent, + type AguiToolCallArgs, + type AguiToolCallEnd, + type AguiToolCallResult, + type AguiToolCallStart, + buildAguiRunInput, + parseAguiEvent, +} from '../agui-types'; +import { describe, expect, it } from 'vitest'; + +describe('AguiEventType enum', () => { + it('contains all expected event types', () => { + // Verify total count to catch accidental additions/removals + const enumValues = Object.values(AguiEventType); + expect(enumValues).toHaveLength(28); + + // Spot-check representative values from each category + expect(AguiEventType.RUN_STARTED).toBe('RUN_STARTED'); + expect(AguiEventType.RUN_FINISHED).toBe('RUN_FINISHED'); + expect(AguiEventType.RUN_ERROR).toBe('RUN_ERROR'); + expect(AguiEventType.STEP_STARTED).toBe('STEP_STARTED'); + expect(AguiEventType.STEP_FINISHED).toBe('STEP_FINISHED'); + expect(AguiEventType.TEXT_MESSAGE_START).toBe('TEXT_MESSAGE_START'); + expect(AguiEventType.TEXT_MESSAGE_CONTENT).toBe('TEXT_MESSAGE_CONTENT'); + expect(AguiEventType.TEXT_MESSAGE_END).toBe('TEXT_MESSAGE_END'); + expect(AguiEventType.TEXT_MESSAGE_CHUNK).toBe('TEXT_MESSAGE_CHUNK'); + expect(AguiEventType.TOOL_CALL_START).toBe('TOOL_CALL_START'); + expect(AguiEventType.TOOL_CALL_ARGS).toBe('TOOL_CALL_ARGS'); + expect(AguiEventType.TOOL_CALL_END).toBe('TOOL_CALL_END'); + expect(AguiEventType.TOOL_CALL_RESULT).toBe('TOOL_CALL_RESULT'); + expect(AguiEventType.TOOL_CALL_CHUNK).toBe('TOOL_CALL_CHUNK'); + expect(AguiEventType.STATE_SNAPSHOT).toBe('STATE_SNAPSHOT'); + expect(AguiEventType.STATE_DELTA).toBe('STATE_DELTA'); + expect(AguiEventType.MESSAGES_SNAPSHOT).toBe('MESSAGES_SNAPSHOT'); + expect(AguiEventType.ACTIVITY_SNAPSHOT).toBe('ACTIVITY_SNAPSHOT'); + expect(AguiEventType.ACTIVITY_DELTA).toBe('ACTIVITY_DELTA'); + expect(AguiEventType.REASONING_START).toBe('REASONING_START'); + expect(AguiEventType.REASONING_MESSAGE_START).toBe('REASONING_MESSAGE_START'); + expect(AguiEventType.REASONING_MESSAGE_CONTENT).toBe('REASONING_MESSAGE_CONTENT'); + expect(AguiEventType.REASONING_MESSAGE_END).toBe('REASONING_MESSAGE_END'); + expect(AguiEventType.REASONING_END).toBe('REASONING_END'); + expect(AguiEventType.REASONING_ENCRYPTED_VALUE).toBe('REASONING_ENCRYPTED_VALUE'); + expect(AguiEventType.RAW).toBe('RAW'); + expect(AguiEventType.CUSTOM).toBe('CUSTOM'); + expect(AguiEventType.META_EVENT).toBe('META_EVENT'); + }); +}); + +describe('AguiErrorCode enum', () => { + it('contains all expected error codes', () => { + expect(AguiErrorCode.AGENT_ERROR).toBe('AGENT_ERROR'); + expect(AguiErrorCode.UNAUTHORIZED).toBe('UNAUTHORIZED'); + expect(AguiErrorCode.ACCESS_DENIED).toBe('ACCESS_DENIED'); + expect(AguiErrorCode.VALIDATION_ERROR).toBe('VALIDATION_ERROR'); + expect(AguiErrorCode.RATE_LIMIT_EXCEEDED).toBe('RATE_LIMIT_EXCEEDED'); + }); +}); + +describe('parseAguiEvent', () => { + describe('lifecycle events', () => { + it('parses RUN_STARTED', () => { + const event = parseAguiEvent('data: {"type":"RUN_STARTED","threadId":"t-1","runId":"r-1"}'); + expect(event).not.toBeNull(); + expect(event!.type).toBe(AguiEventType.RUN_STARTED); + const started = event as AguiRunStarted; + expect(started.threadId).toBe('t-1'); + expect(started.runId).toBe('r-1'); + }); + + it('parses RUN_FINISHED', () => { + const event = parseAguiEvent('data: {"type":"RUN_FINISHED","threadId":"t-1","runId":"r-1"}'); + expect(event).not.toBeNull(); + const finished = event as AguiRunFinished; + expect(finished.type).toBe(AguiEventType.RUN_FINISHED); + expect(finished.threadId).toBe('t-1'); + }); + + it('parses RUN_ERROR', () => { + const event = parseAguiEvent('data: {"type":"RUN_ERROR","message":"Agent failed","code":"AGENT_ERROR"}'); + expect(event).not.toBeNull(); + const error = event as AguiRunError; + expect(error.type).toBe(AguiEventType.RUN_ERROR); + expect(error.message).toBe('Agent failed'); + expect(error.code).toBe('AGENT_ERROR'); + }); + + it('parses STEP_STARTED', () => { + const event = parseAguiEvent('data: {"type":"STEP_STARTED","stepName":"chat_node"}'); + expect(event).not.toBeNull(); + const step = event as AguiStepStarted; + expect(step.type).toBe(AguiEventType.STEP_STARTED); + expect(step.stepName).toBe('chat_node'); + }); + }); + + describe('text message events', () => { + it('parses TEXT_MESSAGE_CONTENT with delta', () => { + const event = parseAguiEvent('data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-1","delta":"Hello"}'); + expect(event).not.toBeNull(); + const content = event as AguiTextMessageContent; + expect(content.type).toBe(AguiEventType.TEXT_MESSAGE_CONTENT); + expect(content.messageId).toBe('msg-1'); + expect(content.delta).toBe('Hello'); + }); + + it('parses TEXT_MESSAGE_CONTENT with unicode', () => { + const event = parseAguiEvent('data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-1","delta":"Hello world!"}'); + expect(event).not.toBeNull(); + expect((event as AguiTextMessageContent).delta).toBe('Hello world!'); + }); + }); + + describe('tool call events', () => { + it('parses TOOL_CALL_START', () => { + const event = parseAguiEvent( + 'data: {"type":"TOOL_CALL_START","toolCallId":"tc-1","toolCallName":"add_numbers","parentMessageId":"msg-1"}' + ); + expect(event).not.toBeNull(); + const start = event as AguiToolCallStart; + expect(start.type).toBe(AguiEventType.TOOL_CALL_START); + expect(start.toolCallId).toBe('tc-1'); + expect(start.toolCallName).toBe('add_numbers'); + expect(start.parentMessageId).toBe('msg-1'); + }); + + it('parses TOOL_CALL_ARGS', () => { + const event = parseAguiEvent('data: {"type":"TOOL_CALL_ARGS","toolCallId":"tc-1","delta":"{\\"a\\": 1"}'); + expect(event).not.toBeNull(); + const args = event as AguiToolCallArgs; + expect(args.type).toBe(AguiEventType.TOOL_CALL_ARGS); + expect(args.toolCallId).toBe('tc-1'); + expect(args.delta).toBe('{"a": 1'); + }); + + it('parses TOOL_CALL_END', () => { + const event = parseAguiEvent('data: {"type":"TOOL_CALL_END","toolCallId":"tc-1"}'); + expect(event).not.toBeNull(); + const end = event as AguiToolCallEnd; + expect(end.type).toBe(AguiEventType.TOOL_CALL_END); + expect(end.toolCallId).toBe('tc-1'); + }); + + it('parses TOOL_CALL_RESULT', () => { + const event = parseAguiEvent( + 'data: {"type":"TOOL_CALL_RESULT","messageId":"msg-2","toolCallId":"tc-1","content":"42"}' + ); + expect(event).not.toBeNull(); + const result = event as AguiToolCallResult; + expect(result.type).toBe(AguiEventType.TOOL_CALL_RESULT); + expect(result.toolCallId).toBe('tc-1'); + expect(result.content).toBe('42'); + }); + }); + + describe('state events', () => { + it('parses STATE_SNAPSHOT', () => { + const event = parseAguiEvent('data: {"type":"STATE_SNAPSHOT","snapshot":{"count":5}}'); + expect(event).not.toBeNull(); + const snapshot = event as AguiStateSnapshot; + expect(snapshot.type).toBe(AguiEventType.STATE_SNAPSHOT); + expect(snapshot.snapshot).toEqual({ count: 5 }); + }); + + it('parses STATE_DELTA', () => { + const event = parseAguiEvent('data: {"type":"STATE_DELTA","delta":[{"op":"replace","path":"/count","value":6}]}'); + expect(event).not.toBeNull(); + const delta = event as AguiStateDelta; + expect(delta.type).toBe(AguiEventType.STATE_DELTA); + expect(delta.delta).toHaveLength(1); + expect(delta.delta[0]!.op).toBe('replace'); + }); + }); + + describe('reasoning events', () => { + it('parses REASONING_MESSAGE_CONTENT', () => { + const event = parseAguiEvent( + 'data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"msg-1","delta":"Let me think..."}' + ); + expect(event).not.toBeNull(); + const reasoning = event as AguiReasoningMessageContent; + expect(reasoning.type).toBe(AguiEventType.REASONING_MESSAGE_CONTENT); + expect(reasoning.delta).toBe('Let me think...'); + }); + }); + + describe('special events', () => { + it('parses CUSTOM event', () => { + const event = parseAguiEvent('data: {"type":"CUSTOM","name":"PredictState","value":{"key":"doc"}}'); + expect(event).not.toBeNull(); + const custom = event as AguiCustomEvent; + expect(custom.type).toBe(AguiEventType.CUSTOM); + expect(custom.name).toBe('PredictState'); + expect(custom.value).toEqual({ key: 'doc' }); + }); + }); + + describe('error handling', () => { + it('returns null for non-data lines', () => { + expect(parseAguiEvent('event: TEXT_MESSAGE_CONTENT')).toBeNull(); + expect(parseAguiEvent('')).toBeNull(); + expect(parseAguiEvent('retry: 5000')).toBeNull(); + }); + + it('returns null for empty data lines', () => { + expect(parseAguiEvent('data: ')).toBeNull(); + expect(parseAguiEvent('data: ')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseAguiEvent('data: {not valid json}')).toBeNull(); + }); + + it('returns null for JSON without type field', () => { + expect(parseAguiEvent('data: {"delta":"hello"}')).toBeNull(); + }); + + it('handles unknown event types gracefully', () => { + const event = parseAguiEvent('data: {"type":"FUTURE_EVENT_TYPE","someField":"value"}'); + expect(event).not.toBeNull(); + expect(event!.type).toBe('FUTURE_EVENT_TYPE'); + }); + }); +}); + +describe('buildAguiRunInput', () => { + it('creates minimal input from prompt', () => { + const input = buildAguiRunInput('Hello'); + expect(input.threadId).toBeDefined(); + expect(input.runId).toBeDefined(); + expect(input.messages).toHaveLength(1); + expect(input.messages[0]!.role).toBe('user'); + expect(input.messages[0]!.content).toBe('Hello'); + expect(input.tools).toEqual([]); + expect(input.context).toEqual([]); + expect(input.state).toEqual({}); + expect(input.forwardedProps).toEqual({}); + }); + + it('uses provided threadId and runId', () => { + const input = buildAguiRunInput('Hello', 'my-thread', 'my-run'); + expect(input.threadId).toBe('my-thread'); + expect(input.runId).toBe('my-run'); + }); + + it('generates unique IDs per call', () => { + const a = buildAguiRunInput('Hello'); + const b = buildAguiRunInput('Hello'); + expect(a.threadId).not.toBe(b.threadId); + expect(a.runId).not.toBe(b.runId); + }); +}); diff --git a/src/cli/aws/__tests__/partition.test.ts b/src/cli/aws/__tests__/partition.test.ts new file mode 100644 index 000000000..0c5ed97ba --- /dev/null +++ b/src/cli/aws/__tests__/partition.test.ts @@ -0,0 +1,76 @@ +import { arnPrefix, consoleDomain, dnsSuffix, getPartition, serviceEndpoint } from '../partition'; +import { describe, expect, it } from 'vitest'; + +describe('getPartition', () => { + it('returns aws for standard commercial regions', () => { + expect(getPartition('us-east-1')).toBe('aws'); + expect(getPartition('eu-west-1')).toBe('aws'); + expect(getPartition('ap-southeast-1')).toBe('aws'); + }); + + it('returns aws-us-gov for GovCloud regions', () => { + expect(getPartition('us-gov-west-1')).toBe('aws-us-gov'); + expect(getPartition('us-gov-east-1')).toBe('aws-us-gov'); + }); + + it('returns aws-cn for China regions', () => { + expect(getPartition('cn-north-1')).toBe('aws-cn'); + expect(getPartition('cn-northwest-1')).toBe('aws-cn'); + }); +}); + +describe('arnPrefix', () => { + it('returns arn:aws for commercial regions', () => { + expect(arnPrefix('us-east-1')).toBe('arn:aws'); + }); + + it('returns arn:aws-us-gov for GovCloud regions', () => { + expect(arnPrefix('us-gov-west-1')).toBe('arn:aws-us-gov'); + }); + + it('returns arn:aws-cn for China regions', () => { + expect(arnPrefix('cn-north-1')).toBe('arn:aws-cn'); + }); +}); + +describe('dnsSuffix', () => { + it('returns amazonaws.com for commercial regions', () => { + expect(dnsSuffix('us-east-1')).toBe('amazonaws.com'); + }); + + it('returns amazonaws.com for GovCloud regions', () => { + expect(dnsSuffix('us-gov-west-1')).toBe('amazonaws.com'); + }); + + it('returns amazonaws.com.cn for China regions', () => { + expect(dnsSuffix('cn-north-1')).toBe('amazonaws.com.cn'); + }); +}); + +describe('serviceEndpoint', () => { + it('builds correct endpoint for commercial regions', () => { + expect(serviceEndpoint('bedrock-agentcore', 'us-east-1')).toBe('bedrock-agentcore.us-east-1.amazonaws.com'); + }); + + it('builds correct endpoint for GovCloud regions', () => { + expect(serviceEndpoint('bedrock-agentcore', 'us-gov-west-1')).toBe('bedrock-agentcore.us-gov-west-1.amazonaws.com'); + }); + + it('builds correct endpoint for China regions', () => { + expect(serviceEndpoint('bedrock-agentcore', 'cn-north-1')).toBe('bedrock-agentcore.cn-north-1.amazonaws.com.cn'); + }); +}); + +describe('consoleDomain', () => { + it('returns console.aws.amazon.com for commercial regions', () => { + expect(consoleDomain('us-east-1')).toBe('console.aws.amazon.com'); + }); + + it('returns console.amazonaws-us-gov.com for GovCloud regions', () => { + expect(consoleDomain('us-gov-west-1')).toBe('console.amazonaws-us-gov.com'); + }); + + it('returns console.amazonaws.cn for China regions', () => { + expect(consoleDomain('cn-north-1')).toBe('console.amazonaws.cn'); + }); +}); diff --git a/src/cli/aws/__tests__/target-region.test.ts b/src/cli/aws/__tests__/target-region.test.ts new file mode 100644 index 000000000..21ec6c8af --- /dev/null +++ b/src/cli/aws/__tests__/target-region.test.ts @@ -0,0 +1,99 @@ +import { applyTargetRegionToEnv, withTargetRegion } from '../target-region.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('target-region', () => { + let savedRegion: string | undefined; + let savedDefaultRegion: string | undefined; + + beforeEach(() => { + savedRegion = process.env.AWS_REGION; + savedDefaultRegion = process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + }); + + afterEach(() => { + if (savedRegion !== undefined) process.env.AWS_REGION = savedRegion; + else delete process.env.AWS_REGION; + if (savedDefaultRegion !== undefined) process.env.AWS_DEFAULT_REGION = savedDefaultRegion; + else delete process.env.AWS_DEFAULT_REGION; + }); + + describe('applyTargetRegionToEnv', () => { + it('sets AWS_REGION and AWS_DEFAULT_REGION to the provided region', () => { + applyTargetRegionToEnv('ap-southeast-2'); + expect(process.env.AWS_REGION).toBe('ap-southeast-2'); + expect(process.env.AWS_DEFAULT_REGION).toBe('ap-southeast-2'); + }); + + it('returns a restore function that clears env vars when they were previously unset', () => { + const restore = applyTargetRegionToEnv('eu-west-1'); + restore(); + expect(process.env.AWS_REGION).toBeUndefined(); + expect(process.env.AWS_DEFAULT_REGION).toBeUndefined(); + }); + + it('returns a restore function that restores previous env var values', () => { + process.env.AWS_REGION = 'us-east-1'; + process.env.AWS_DEFAULT_REGION = 'us-east-1'; + + const restore = applyTargetRegionToEnv('ap-south-1'); + expect(process.env.AWS_REGION).toBe('ap-south-1'); + expect(process.env.AWS_DEFAULT_REGION).toBe('ap-south-1'); + + restore(); + expect(process.env.AWS_REGION).toBe('us-east-1'); + expect(process.env.AWS_DEFAULT_REGION).toBe('us-east-1'); + }); + + it('restores each env var independently (only one was previously set)', () => { + process.env.AWS_REGION = 'us-west-2'; + // AWS_DEFAULT_REGION intentionally left unset + + const restore = applyTargetRegionToEnv('eu-central-1'); + expect(process.env.AWS_REGION).toBe('eu-central-1'); + expect(process.env.AWS_DEFAULT_REGION).toBe('eu-central-1'); + + restore(); + expect(process.env.AWS_REGION).toBe('us-west-2'); + expect(process.env.AWS_DEFAULT_REGION).toBeUndefined(); + }); + }); + + describe('withTargetRegion', () => { + it('applies region inside the callback and restores afterwards', async () => { + let seenRegion: string | undefined; + let seenDefaultRegion: string | undefined; + + await withTargetRegion('ap-northeast-1', () => { + seenRegion = process.env.AWS_REGION; + seenDefaultRegion = process.env.AWS_DEFAULT_REGION; + return Promise.resolve(); + }); + + expect(seenRegion).toBe('ap-northeast-1'); + expect(seenDefaultRegion).toBe('ap-northeast-1'); + expect(process.env.AWS_REGION).toBeUndefined(); + expect(process.env.AWS_DEFAULT_REGION).toBeUndefined(); + }); + + it('restores env vars even when the callback throws', async () => { + process.env.AWS_REGION = 'us-east-1'; + + await expect( + withTargetRegion('sa-east-1', () => { + expect(process.env.AWS_REGION).toBe('sa-east-1'); + return Promise.reject(new Error('boom')); + }) + ).rejects.toThrow('boom'); + + expect(process.env.AWS_REGION).toBe('us-east-1'); + expect(process.env.AWS_DEFAULT_REGION).toBeUndefined(); + }); + + it('returns the callback result', async () => { + const result = await withTargetRegion('eu-west-2', () => Promise.resolve(42)); + expect(result).toBe(42); + }); + }); +}); diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index b911a373f..477358bdc 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -1,5 +1,7 @@ import { parseJsonRpcResponse } from '../../lib/utils/json-rpc'; import { getCredentialProvider } from './account'; +import { parseAguiSSEStream } from './agui-parser'; +import { serviceEndpoint } from './partition'; import { BedrockAgentCoreClient, EvaluateCommand, @@ -149,11 +151,10 @@ export function extractResult(text: string): string { /** * Build the invoke URL for a runtime ARN. - * Format: https://bedrock-agentcore.{REGION}.amazonaws.com/runtimes/{ESCAPED_ARN}/invocations?qualifier=DEFAULT */ function buildInvokeUrl(region: string, runtimeArn: string): string { const escapedArn = encodeURIComponent(runtimeArn); - return `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/${escapedArn}/invocations?qualifier=DEFAULT`; + return `https://${serviceEndpoint('bedrock-agentcore', region)}/runtimes/${escapedArn}/invocations?qualifier=DEFAULT`; } /** @@ -865,6 +866,7 @@ export interface A2AInvokeOptions { region: string; runtimeArn: string; userId?: string; + sessionId?: string; logger?: SSELogger; /** Custom headers to forward to the agent runtime */ headers?: Record; @@ -900,6 +902,7 @@ export async function invokeA2ARuntime(options: A2AInvokeOptions, message: strin contentType: 'application/json', accept: 'application/json, text/event-stream', runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, + ...(options.sessionId && { runtimeSessionId: options.sessionId }), }); const response = await client.send(command); @@ -982,6 +985,78 @@ export function parseA2AResponse(text: string): string { } } +// --------------------------------------------------------------------------- +// AGUI: Structured event streaming over InvokeAgentRuntime +// --------------------------------------------------------------------------- + +export interface AguiInvokeOptions { + region: string; + runtimeArn: string; + sessionId?: string; + userId?: string; + logger?: SSELogger; + headers?: Record; + /** Bearer token for CUSTOM_JWT auth — not yet supported for AGUI, will throw if provided */ + bearerToken?: string; +} + +export interface AguiStreamingInvokeResult { + /** Typed event stream — yields all AGUI events for rich TUI rendering */ + stream: AsyncGenerator; + /** Text-only convenience stream — yields only TEXT_MESSAGE_CONTENT deltas */ + textStream: AsyncGenerator; + sessionId: string | undefined; +} + +/** + * Invoke an AgentCore AGUI Runtime and stream structured events. + * Returns both a typed event stream and a text-only convenience stream. + */ +export async function invokeAguiRuntime( + options: AguiInvokeOptions, + input: import('./agui-types').AguiRunInput +): Promise { + if (options.bearerToken) { + throw new Error('Bearer token auth is not yet supported for AGUI. Use SigV4 credentials.'); + } + + const client = createAgentCoreClient(options.region, options.headers); + + const command = new InvokeAgentRuntimeCommand({ + agentRuntimeArn: options.runtimeArn, + payload: new TextEncoder().encode(JSON.stringify(input)), + contentType: 'application/json', + accept: 'text/event-stream', + runtimeSessionId: options.sessionId, + runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, + }); + + const response = await client.send(command); + const sessionId = response.runtimeSessionId; + + if (!response.response) { + throw new Error('No response from AgentCore Runtime'); + } + + const webStream = response.response.transformToWebStream(); + const reader = webStream.getReader(); + + const { eventStream, textStream } = parseAguiSSEStream({ + reader, + logger: options.logger, + }); + + if (!textStream) { + throw new Error('AGUI parser created in single-consumer mode — textStream unavailable'); + } + + return { + stream: eventStream, + textStream, + sessionId, + }; +} + /** * Stop a runtime session. */ diff --git a/src/cli/aws/agui-parser.ts b/src/cli/aws/agui-parser.ts new file mode 100644 index 000000000..f2afc1d95 --- /dev/null +++ b/src/cli/aws/agui-parser.ts @@ -0,0 +1,123 @@ +import type { SSELogger } from '../operations/dev/invoke-types'; +import { AguiEventType, parseAguiEvent } from './agui-types'; +import type { AguiEvent, AguiTextMessageContent } from './agui-types'; + +export interface ParseAguiSSEOptions { + reader: ReadableStreamDefaultReader; + logger?: SSELogger; + singleConsumer?: boolean; +} + +export interface AguiSSEStreams { + eventStream: AsyncGenerator; + textStream?: AsyncGenerator; +} + +export function parseAguiSSEStream(options: ParseAguiSSEOptions): AguiSSEStreams { + const { reader, logger, singleConsumer = false } = options; + const decoder = new TextDecoder(); + + const events: AguiEvent[] = []; + const waiters: (() => void)[] = []; + let done = false; + let readError: Error | undefined; + let eventCursor = 0; + let textCursor = singleConsumer ? -1 : 0; + + function notify() { + for (const w of waiters.splice(0)) w(); + } + + function prune() { + const minCursor = textCursor === -1 ? eventCursor : Math.min(eventCursor, textCursor); + if (minCursor > 0) { + events.splice(0, minCursor); + eventCursor -= minCursor; + if (textCursor !== -1) textCursor -= minCursor; + } + } + + const readLoop = (async () => { + let buffer = ''; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + + buffer += decoder.decode(result.value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (logger && line.trim()) { + logger.logSSEEvent(line); + } + const event = parseAguiEvent(line); + if (event) { + events.push(event); + notify(); + } + } + } + if (buffer.trim()) { + if (logger) logger.logSSEEvent(buffer); + const event = parseAguiEvent(buffer); + if (event) events.push(event); + } + } finally { + try { + reader.releaseLock(); + } catch { + // already released + } + done = true; + notify(); + } + })(); + + readLoop.catch((err: unknown) => { + readError = err instanceof Error ? err : new Error(String(err)); + done = true; + notify(); + }); + + async function* makeEventStream(): AsyncGenerator { + while (true) { + if (eventCursor < events.length) { + const event = events[eventCursor++]!; + prune(); + yield event; + } else if (done) { + if (readError) throw readError; + return; + } else { + await new Promise(resolve => waiters.push(resolve)); + } + } + } + + async function* makeTextStream(): AsyncGenerator { + while (true) { + if (textCursor < events.length) { + const event = events[textCursor++]!; + prune(); + if (event.type === AguiEventType.TEXT_MESSAGE_CONTENT || event.type === AguiEventType.TEXT_MESSAGE_CHUNK) { + const delta = (event as AguiTextMessageContent).delta; + if (delta) yield delta; + } else if (event.type === AguiEventType.RUN_ERROR) { + yield `Error: ${event.message}`; + } + } else if (done) { + if (readError) throw readError; + return; + } else { + await new Promise(resolve => waiters.push(resolve)); + } + } + } + + return { + eventStream: makeEventStream(), + textStream: singleConsumer ? undefined : makeTextStream(), + }; +} diff --git a/src/cli/aws/agui-types.ts b/src/cli/aws/agui-types.ts new file mode 100644 index 000000000..18b8fd914 --- /dev/null +++ b/src/cli/aws/agui-types.ts @@ -0,0 +1,406 @@ +/** + * TypeScript type definitions for AG-UI protocol events. + * AG-UI is an event-based protocol for agent-to-user interaction. + * + * Events are streamed as SSE with type-in-JSON format: + * data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-1","delta":"Hello"}\n\n + * + * @see https://docs.ag-ui.com/concepts/events + */ + +// ============================================================================ +// Event Type Enum +// ============================================================================ + +export enum AguiEventType { + // Lifecycle + RUN_STARTED = 'RUN_STARTED', + RUN_FINISHED = 'RUN_FINISHED', + RUN_ERROR = 'RUN_ERROR', + STEP_STARTED = 'STEP_STARTED', + STEP_FINISHED = 'STEP_FINISHED', + + // Text Message (streaming triplet) + TEXT_MESSAGE_START = 'TEXT_MESSAGE_START', + TEXT_MESSAGE_CONTENT = 'TEXT_MESSAGE_CONTENT', + TEXT_MESSAGE_END = 'TEXT_MESSAGE_END', + TEXT_MESSAGE_CHUNK = 'TEXT_MESSAGE_CHUNK', + + // Tool Call + TOOL_CALL_START = 'TOOL_CALL_START', + TOOL_CALL_ARGS = 'TOOL_CALL_ARGS', + TOOL_CALL_END = 'TOOL_CALL_END', + TOOL_CALL_RESULT = 'TOOL_CALL_RESULT', + TOOL_CALL_CHUNK = 'TOOL_CALL_CHUNK', + + // State Management + STATE_SNAPSHOT = 'STATE_SNAPSHOT', + STATE_DELTA = 'STATE_DELTA', + MESSAGES_SNAPSHOT = 'MESSAGES_SNAPSHOT', + + // Activity + ACTIVITY_SNAPSHOT = 'ACTIVITY_SNAPSHOT', + ACTIVITY_DELTA = 'ACTIVITY_DELTA', + + // Reasoning + REASONING_START = 'REASONING_START', + REASONING_MESSAGE_START = 'REASONING_MESSAGE_START', + REASONING_MESSAGE_CONTENT = 'REASONING_MESSAGE_CONTENT', + REASONING_MESSAGE_END = 'REASONING_MESSAGE_END', + REASONING_END = 'REASONING_END', + REASONING_ENCRYPTED_VALUE = 'REASONING_ENCRYPTED_VALUE', + + // Special + RAW = 'RAW', + CUSTOM = 'CUSTOM', + META_EVENT = 'META_EVENT', +} + +// ============================================================================ +// Error Codes +// ============================================================================ + +export enum AguiErrorCode { + AGENT_ERROR = 'AGENT_ERROR', + UNAUTHORIZED = 'UNAUTHORIZED', + ACCESS_DENIED = 'ACCESS_DENIED', + VALIDATION_ERROR = 'VALIDATION_ERROR', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', +} + +// ============================================================================ +// Base Event +// ============================================================================ + +export interface AguiBaseEvent { + type: AguiEventType; + timestamp?: number; + rawEvent?: unknown; +} + +// ============================================================================ +// Lifecycle Events +// ============================================================================ + +export interface AguiRunStarted extends AguiBaseEvent { + type: AguiEventType.RUN_STARTED; + threadId: string; + runId: string; + parentRunId?: string; +} + +export interface AguiRunFinished extends AguiBaseEvent { + type: AguiEventType.RUN_FINISHED; + threadId: string; + runId: string; + result?: unknown; +} + +export interface AguiRunError extends AguiBaseEvent { + type: AguiEventType.RUN_ERROR; + message: string; + code?: string; +} + +export interface AguiStepStarted extends AguiBaseEvent { + type: AguiEventType.STEP_STARTED; + stepName: string; +} + +export interface AguiStepFinished extends AguiBaseEvent { + type: AguiEventType.STEP_FINISHED; + stepName: string; +} + +// ============================================================================ +// Text Message Events +// ============================================================================ + +export interface AguiTextMessageStart extends AguiBaseEvent { + type: AguiEventType.TEXT_MESSAGE_START; + messageId: string; + role: string; +} + +export interface AguiTextMessageContent extends AguiBaseEvent { + type: AguiEventType.TEXT_MESSAGE_CONTENT; + messageId: string; + delta: string; +} + +export interface AguiTextMessageEnd extends AguiBaseEvent { + type: AguiEventType.TEXT_MESSAGE_END; + messageId: string; +} + +// ============================================================================ +// Tool Call Events +// ============================================================================ + +export interface AguiToolCallStart extends AguiBaseEvent { + type: AguiEventType.TOOL_CALL_START; + toolCallId: string; + toolCallName: string; + parentMessageId?: string; +} + +export interface AguiToolCallArgs extends AguiBaseEvent { + type: AguiEventType.TOOL_CALL_ARGS; + toolCallId: string; + delta: string; +} + +export interface AguiToolCallEnd extends AguiBaseEvent { + type: AguiEventType.TOOL_CALL_END; + toolCallId: string; +} + +export interface AguiToolCallResult extends AguiBaseEvent { + type: AguiEventType.TOOL_CALL_RESULT; + messageId: string; + toolCallId: string; + content: unknown; + role?: string; +} + +// ============================================================================ +// State Management Events +// ============================================================================ + +export interface AguiStateSnapshot extends AguiBaseEvent { + type: AguiEventType.STATE_SNAPSHOT; + snapshot: Record; +} + +export interface AguiStateDelta extends AguiBaseEvent { + type: AguiEventType.STATE_DELTA; + delta: { op: string; path: string; value?: unknown }[]; +} + +export interface AguiMessagesSnapshot extends AguiBaseEvent { + type: AguiEventType.MESSAGES_SNAPSHOT; + messages: unknown[]; +} + +// ============================================================================ +// Activity Events +// ============================================================================ + +export interface AguiActivitySnapshot extends AguiBaseEvent { + type: AguiEventType.ACTIVITY_SNAPSHOT; + messageId: string; + activityType: string; + content: Record; + replace?: boolean; +} + +export interface AguiActivityDelta extends AguiBaseEvent { + type: AguiEventType.ACTIVITY_DELTA; + messageId: string; + activityType: string; + patch: { op: string; path: string; value?: unknown }[]; +} + +// ============================================================================ +// Reasoning Events +// ============================================================================ + +export interface AguiReasoningStart extends AguiBaseEvent { + type: AguiEventType.REASONING_START; + messageId: string; +} + +export interface AguiReasoningMessageStart extends AguiBaseEvent { + type: AguiEventType.REASONING_MESSAGE_START; + messageId: string; + role: string; +} + +export interface AguiReasoningMessageContent extends AguiBaseEvent { + type: AguiEventType.REASONING_MESSAGE_CONTENT; + messageId: string; + delta: string; +} + +export interface AguiReasoningMessageEnd extends AguiBaseEvent { + type: AguiEventType.REASONING_MESSAGE_END; + messageId: string; +} + +export interface AguiReasoningEnd extends AguiBaseEvent { + type: AguiEventType.REASONING_END; + messageId: string; +} + +// ============================================================================ +// Additional Text / Tool Chunk Events +// ============================================================================ + +export interface AguiTextMessageChunk extends AguiBaseEvent { + type: AguiEventType.TEXT_MESSAGE_CHUNK; + messageId: string; + role?: string; + delta?: string; + content?: string; +} + +export interface AguiToolCallChunk extends AguiBaseEvent { + type: AguiEventType.TOOL_CALL_CHUNK; + toolCallId: string; + delta?: string; +} + +// ============================================================================ +// Additional Reasoning Events +// ============================================================================ + +export interface AguiReasoningEncryptedValue extends AguiBaseEvent { + type: AguiEventType.REASONING_ENCRYPTED_VALUE; + messageId: string; + data: string; +} + +// ============================================================================ +// Special Events +// ============================================================================ + +export interface AguiRawEvent extends AguiBaseEvent { + type: AguiEventType.RAW; + event: unknown; + source?: string; +} + +export interface AguiCustomEvent extends AguiBaseEvent { + type: AguiEventType.CUSTOM; + name: string; + value: unknown; +} + +export interface AguiMetaEvent extends AguiBaseEvent { + type: AguiEventType.META_EVENT; + name: string; + value: unknown; +} + +// ============================================================================ +// Union type of all known events +// ============================================================================ + +export type AguiEvent = + | AguiRunStarted + | AguiRunFinished + | AguiRunError + | AguiStepStarted + | AguiStepFinished + | AguiTextMessageStart + | AguiTextMessageContent + | AguiTextMessageEnd + | AguiTextMessageChunk + | AguiToolCallStart + | AguiToolCallArgs + | AguiToolCallEnd + | AguiToolCallResult + | AguiToolCallChunk + | AguiStateSnapshot + | AguiStateDelta + | AguiMessagesSnapshot + | AguiActivitySnapshot + | AguiActivityDelta + | AguiReasoningStart + | AguiReasoningMessageStart + | AguiReasoningMessageContent + | AguiReasoningMessageEnd + | AguiReasoningEnd + | AguiReasoningEncryptedValue + | AguiRawEvent + | AguiCustomEvent + | AguiMetaEvent; + +// ============================================================================ +// RunAgentInput (request body for AGUI invocations) +// ============================================================================ + +export interface AguiMessage { + id: string; + role: string; + content: string | unknown[]; + name?: string; + toolCallId?: string; +} + +export interface AguiTool { + name: string; + description: string; + parameters: Record; +} + +export interface AguiContext { + description: string; + value: string; +} + +export interface AguiRunInput { + threadId: string; + runId: string; + messages: AguiMessage[]; + tools?: AguiTool[]; + context?: AguiContext[]; + state?: Record; + forwardedProps?: Record; +} + +// ============================================================================ +// Parser +// ============================================================================ + +/** + * Parse a single SSE data line into a typed AGUI event. + * Expects type-in-JSON format: data: {"type":"TEXT_MESSAGE_CONTENT","delta":"Hello"} + * Returns null for non-data lines, empty lines, or malformed payloads. + */ +export function parseAguiEvent(line: string): AguiEvent | null { + if (!line.startsWith('data: ')) { + return null; + } + + const jsonStr = line.slice(6).trim(); + if (!jsonStr) { + return null; + } + + try { + const parsed: unknown = JSON.parse(jsonStr); + if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) { + return null; + } + + const event = parsed as Record; + const type = event.type as string; + + // Validate the type is a known AguiEventType + if (!Object.values(AguiEventType).includes(type as AguiEventType)) { + // Return as a base event for forward compatibility with unknown types + return { type: type as AguiEventType, ...event } as unknown as AguiEvent; + } + + return event as unknown as AguiEvent; + } catch { + return null; + } +} + +/** + * Construct a minimal AguiRunInput from a user prompt string. + * Generates fresh threadId and runId for single-turn invocations. + */ +export function buildAguiRunInput(prompt: string, threadId?: string, runId?: string): AguiRunInput { + return { + threadId: threadId ?? crypto.randomUUID(), + runId: runId ?? crypto.randomUUID(), + messages: [{ id: crypto.randomUUID(), role: 'user', content: prompt }], + tools: [], + context: [], + state: {}, + forwardedProps: {}, + }; +} diff --git a/src/cli/aws/bedrock-import.ts b/src/cli/aws/bedrock-import.ts index c5c404df7..c4ba849fd 100644 --- a/src/cli/aws/bedrock-import.ts +++ b/src/cli/aws/bedrock-import.ts @@ -323,7 +323,7 @@ async function fetchCollaborators( const aliasArn = (summary as { agentDescriptor?: { aliasArn?: string } }).agentDescriptor?.aliasArn; if (!aliasArn) continue; - const arnMatch = /^arn:aws:bedrock:[^:]+:[^:]+:agent-alias\/([^/]+)\/([^/]+)$/.exec(aliasArn); + const arnMatch = /^arn:[^:]+:bedrock:[^:]+:[^:]+:agent-alias\/([^/]+)\/([^/]+)$/.exec(aliasArn); if (!arnMatch) continue; const [, collabAgentId, collabAliasId] = arnMatch; if (!collabAgentId || !collabAliasId) continue; diff --git a/src/cli/aws/cloudwatch.ts b/src/cli/aws/cloudwatch.ts index 5c5658a46..c67b77fcd 100644 --- a/src/cli/aws/cloudwatch.ts +++ b/src/cli/aws/cloudwatch.ts @@ -1,4 +1,5 @@ import { getCredentialProvider } from './account'; +import { arnPrefix } from './partition'; import { CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand } from '@aws-sdk/client-cloudwatch-logs'; export interface LogEvent { @@ -31,7 +32,7 @@ export async function* streamLogs(options: StreamLogsOptions): AsyncGenerator = { + 'aws-us-gov': 'console.amazonaws-us-gov.com', + 'aws-cn': 'console.amazonaws.cn', +}; + +const DEFAULT_CONSOLE_DOMAIN = 'console.aws.amazon.com'; + +export function getPartition(region: string): string { + return partition(region).name; +} + +export function arnPrefix(region: string): string { + return `arn:${getPartition(region)}`; +} + +export function dnsSuffix(region: string): string { + return partition(region).dnsSuffix; +} + +export function serviceEndpoint(service: string, region: string): string { + return `${service}.${region}.${dnsSuffix(region)}`; +} + +export function consoleDomain(region: string): string { + return CONSOLE_DOMAINS[getPartition(region)] ?? DEFAULT_CONSOLE_DOMAIN; +} diff --git a/src/cli/aws/target-region.ts b/src/cli/aws/target-region.ts new file mode 100644 index 000000000..b5d9f837a --- /dev/null +++ b/src/cli/aws/target-region.ts @@ -0,0 +1,55 @@ +/** + * Make a deployment target's region authoritative for downstream AWS SDK calls. + * + * The AWS SDK (and CDK toolkit-lib's internal clients) resolve region from + * AWS_REGION / AWS_DEFAULT_REGION when constructed without an explicit `region` + * option. aws-targets.json is the user's source of truth for where resources + * should be created, so we promote the target's region onto the environment for + * the operation and restore any prior values afterwards. + * + * Without this override, a user with a non-default region in aws-targets.json + * but no AWS_DEFAULT_REGION set would see resources created in the SDK's default + * region — see https://github.com/aws/agentcore-cli/issues/924. + */ + +type RestoreEnv = () => void; + +/** + * Set AWS_REGION / AWS_DEFAULT_REGION to `region` and return a restore function. + * Callers that cannot wrap their work in a callback (e.g. CLI entrypoints that + * span many helpers) should use this, and invoke the returned function in a + * `finally` block. + */ +export function applyTargetRegionToEnv(region: string): RestoreEnv { + const prevRegion = process.env.AWS_REGION; + const prevDefaultRegion = process.env.AWS_DEFAULT_REGION; + + process.env.AWS_REGION = region; + process.env.AWS_DEFAULT_REGION = region; + + return () => { + if (prevRegion === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = prevRegion; + } + if (prevDefaultRegion === undefined) { + delete process.env.AWS_DEFAULT_REGION; + } else { + process.env.AWS_DEFAULT_REGION = prevDefaultRegion; + } + }; +} + +/** + * Run `fn` with `region` applied to AWS_REGION / AWS_DEFAULT_REGION, restoring + * the prior values on return (including when `fn` throws). + */ +export async function withTargetRegion(region: string, fn: () => Promise): Promise { + const restore = applyTargetRegionToEnv(region); + try { + return await fn(); + } finally { + restore(); + } +} diff --git a/src/cli/aws/transaction-search.ts b/src/cli/aws/transaction-search.ts index 3dc9e5896..3a3b53fa6 100644 --- a/src/cli/aws/transaction-search.ts +++ b/src/cli/aws/transaction-search.ts @@ -1,5 +1,6 @@ import { getErrorMessage, isAccessDeniedError } from '../errors'; import { getCredentialProvider } from './account'; +import { arnPrefix } from './partition'; import { ApplicationSignalsClient, StartDiscoveryCommand } from '@aws-sdk/client-application-signals'; import { CloudWatchLogsClient, @@ -64,11 +65,11 @@ export async function enableTransactionSearch( Principal: { Service: 'xray.amazonaws.com' }, Action: 'logs:PutLogEvents', Resource: [ - `arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`, - `arn:aws:logs:${region}:${accountId}:log-group:/aws/application-signals/data:*`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:aws/spans:*`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:/aws/application-signals/data:*`, ], Condition: { - ArnLike: { 'aws:SourceArn': `arn:aws:xray:${region}:${accountId}:*` }, + ArnLike: { 'aws:SourceArn': `${arnPrefix(region)}:xray:${region}:${accountId}:*` }, StringEquals: { 'aws:SourceAccount': accountId }, }, }, diff --git a/src/cli/cli.ts b/src/cli/cli.ts index ae54a956e..aa868fc17 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -28,7 +28,9 @@ import { ALL_PRIMITIVES } from './primitives'; import { App } from './tui/App'; 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'; @@ -108,6 +110,16 @@ function renderTUI(updateCheck: Promise, isFirstRun: b process.stdout.write(EXIT_ALT_SCREEN); process.stdout.write(SHOW_CURSOR); + // Check if the TUI requested a post-exit action (e.g., launch browser dev mode) + const action = getExitAction(); + clearExitAction(); + + if (action?.type === 'dev') { + const { launchBrowserDev } = await import('./commands/dev/browser-mode'); + await launchBrowserDev(); + return; + } + // Print any exit message set by screens (e.g., after successful project creation) const exitMessage = getExitMessage(); if (exitMessage) { @@ -212,6 +224,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; } @@ -224,4 +237,10 @@ export const main = async (argv: string[]) => { // Telemetry notice already printed above; only run update check here. await printPostCommandNotices(false, updateCheck); + + const exitMessage = getExitMessage(); + if (exitMessage) { + console.log(`\n${exitMessage}`); + clearExitMessage(); + } }; diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 8940d0c1f..e020839df 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; + } + // 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) { diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 2b23baa9a..b304afa5c 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -55,6 +55,11 @@ const validGatewayOptionsNone: AddGatewayOptions = { authorizerType: 'NONE', }; +const validGatewayOptionsIam: AddGatewayOptions = { + name: 'test-gateway', + authorizerType: 'AWS_IAM', +}; + const validGatewayOptionsJwt: AddGatewayOptions = { name: 'test-gateway', authorizerType: 'CUSTOM_JWT', @@ -343,6 +348,7 @@ describe('validate', () => { // AC14: Valid options pass it('passes for valid options', () => { expect(validateAddGatewayOptions(validGatewayOptionsNone)).toEqual({ valid: true }); + expect(validateAddGatewayOptions(validGatewayOptionsIam)).toEqual({ valid: true }); expect(validateAddGatewayOptions(validGatewayOptionsJwt)).toEqual({ valid: true }); }); @@ -1520,3 +1526,48 @@ describe('validateAddAgentOptions - lifecycle configuration', () => { expect(result.error).toContain('--idle-timeout'); }); }); + +describe('validateAddAgentOptions - session storage mount path', () => { + const baseOptions: AddAgentOptions = { + name: 'TestAgent', + type: 'byo', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + build: 'CodeZip', + codeLocation: './app/test/', + }; + + it('accepts valid mount path', () => { + const result = validateAddAgentOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/data' }); + expect(result.valid).toBe(true); + }); + + it('accepts mount path with hyphenated subdirectory', () => { + const result = validateAddAgentOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/my-storage' }); + expect(result.valid).toBe(true); + }); + + it('rejects path not under /mnt', () => { + const result = validateAddAgentOptions({ ...baseOptions, sessionStorageMountPath: '/data/storage' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--session-storage-mount-path'); + }); + + it('rejects path with more than one subdirectory level', () => { + const result = validateAddAgentOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/data/subdir' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--session-storage-mount-path'); + }); + + it('rejects bare /mnt with no subdirectory', () => { + const result = validateAddAgentOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--session-storage-mount-path'); + }); + + it('accepts omitted mount path', () => { + const result = validateAddAgentOptions({ ...baseOptions }); + expect(result.valid).toBe(true); + }); +}); 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 57a98206e..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); @@ -342,3 +379,48 @@ describe('validateCreateOptions - lifecycle configuration', () => { expect(result.valid).toBe(true); }); }); + +describe('validateCreateOptions - session storage mount path', () => { + const cwd = join(tmpdir(), `create-session-storage-${randomUUID()}`); + + const baseOptions = { + name: 'TestProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }; + + it('accepts valid mount path', () => { + const result = validateCreateOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/data' }, cwd); + expect(result.valid).toBe(true); + }); + + it('accepts mount path with hyphenated subdirectory', () => { + const result = validateCreateOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/my-storage' }, cwd); + expect(result.valid).toBe(true); + }); + + it('rejects path not under /mnt', () => { + const result = validateCreateOptions({ ...baseOptions, sessionStorageMountPath: '/data/storage' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--session-storage-mount-path'); + }); + + it('rejects path with more than one subdirectory level', () => { + const result = validateCreateOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/data/subdir' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--session-storage-mount-path'); + }); + + it('rejects bare /mnt with no subdirectory', () => { + const result = validateCreateOptions({ ...baseOptions, sessionStorageMountPath: '/mnt/' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--session-storage-mount-path'); + }); + + it('accepts omitted mount path', () => { + const result = validateCreateOptions({ ...baseOptions }, cwd); + expect(result.valid).toBe(true); + }); +}); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index d95316b7f..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; @@ -129,6 +130,7 @@ export interface CreateWithAgentOptions { region?: string; idleTimeout?: number; maxLifetime?: number; + sessionStorageMountPath?: string; skipGit?: boolean; skipInstall?: boolean; skipPythonSetup?: boolean; @@ -138,6 +140,7 @@ export interface CreateWithAgentOptions { export async function createProjectWithAgent(options: CreateWithAgentOptions): Promise { const { name, + projectName: explicitProjectName, cwd, buildType, language, @@ -152,12 +155,14 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P requestHeaderAllowlist, idleTimeout, maxLifetime: maxLifetimeOpt, + sessionStorageMountPath, skipGit, skipInstall, 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) @@ -170,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 ?? [])]; @@ -232,6 +244,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P requestHeaderAllowlist, ...(idleTimeout !== undefined && { idleRuntimeSessionTimeout: idleTimeout }), ...(maxLifetimeOpt !== undefined && { maxLifetime: maxLifetimeOpt }), + ...(sessionStorageMountPath && { sessionStorageMountPath }), }; // Resolve credential strategy FIRST (new project has no existing credentials) @@ -240,7 +253,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P if (!isMcp && resolvedModelProvider !== 'Bedrock') { strategy = await credentialPrimitive.resolveCredentialStrategy( - name, + projectName, agentName, resolvedModelProvider, apiKey, @@ -292,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 7d7c3ff75..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', @@ -145,6 +149,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { securityGroups: parseCommaSeparatedList(options.securityGroups), idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, + sessionStorageMountPath: options.sessionStorageMountPath, skipGit: options.skipGit, skipInstall: options.skipInstall, skipPythonSetup: options.skipPythonSetup, @@ -154,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." @@ -171,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]') @@ -183,7 +192,7 @@ export const registerCreate = (program: Command) => { .option('--model-provider ', 'Model provider (Bedrock, Anthropic, OpenAI, Gemini) [non-interactive]') .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') .option('--memory

    ', '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]') @@ -219,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 ?? @@ -240,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 eee545609..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; @@ -17,6 +18,7 @@ export interface CreateOptions extends VpcOptions { region?: string; idleTimeout?: number | string; maxLifetime?: number | string; + sessionStorageMountPath?: string; outputDir?: string; skipGit?: boolean; skipPythonSetup?: boolean; diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index b4feec354..a59c7d752 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -1,9 +1,11 @@ import { + AgentNameSchema, BuildTypeSchema, ModelProviderSchema, ProjectNameSchema, ProtocolModeSchema, SDKFrameworkSchema, + SessionStorageSchema, TargetLanguageSchema, getSupportedFrameworksForProtocol, getSupportedModelProviders, @@ -35,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 }; } @@ -56,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' }; @@ -91,7 +100,7 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val if (options.protocol) { const protocolResult = ProtocolModeSchema.safeParse(options.protocol); if (!protocolResult.success) { - return { valid: false, error: `Invalid protocol: ${options.protocol}. Use HTTP, MCP, or A2A` }; + return { valid: false, error: `Invalid protocol: ${options.protocol}. Use HTTP, MCP, A2A, or AGUI` }; } protocol = protocolResult.data; } @@ -207,5 +216,13 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val if (lifecycleResult.idleTimeout !== undefined) options.idleTimeout = lifecycleResult.idleTimeout; if (lifecycleResult.maxLifetime !== undefined) options.maxLifetime = lifecycleResult.maxLifetime; + // Validate session storage mount path + if (options.sessionStorageMountPath) { + const mountPathResult = SessionStorageSchema.shape.mountPath.safeParse(options.sessionStorageMountPath); + if (!mountPathResult.success) { + return { valid: false, error: `--session-storage-mount-path: ${mountPathResult.error.issues[0]?.message}` }; + } + } + return { valid: true }; } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index be330ae0f..6a720e5ff 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,5 +1,6 @@ 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 { @@ -12,6 +13,7 @@ import { parseOnlineEvalOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, + parseRuntimeEndpointOutputs, } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; @@ -55,6 +57,7 @@ const MEMORY_ONLY_NEXT_STEPS = ['agentcore add agent', 'agentcore status']; export async function handleDeploy(options: ValidatedDeployOptions): Promise { let toolkitWrapper = null; + let restoreEnv: (() => void) | null = null; const logger = new ExecLogger({ command: 'deploy' }); const { onProgress } = options; let currentStepName = ''; @@ -86,6 +89,10 @@ 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/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts new file mode 100644 index 000000000..3846dea85 --- /dev/null +++ b/src/cli/commands/dev/browser-mode.ts @@ -0,0 +1,207 @@ +import { ConfigIO, findConfigRoot, getWorkingDirectory } from '../../../lib'; +import type { AgentCoreProjectSpec } from '../../../schema'; +import { getDevConfig, getDevSupportedAgents, loadDevEnv, loadProjectConfig } from '../../operations/dev'; +import { type OtelCollector, startOtelCollector } from '../../operations/dev/otel'; +import { + type AgentInfo, + type ListMemoryRecordsHandler, + type RetrieveMemoryRecordsHandler, + runWebUI, +} from '../../operations/dev/web-ui'; +import { listMemoryRecords, retrieveMemoryRecords } from '../../operations/memory'; +import path from 'node:path'; + +interface DeployedHandlers { + onListMemoryRecords?: ListMemoryRecordsHandler; + onRetrieveMemoryRecords?: RetrieveMemoryRecordsHandler; +} + +/** + * Resolve deployed resources (memories, agents) from config and return handlers + * that query them via the AWS SDK. Only resources with "deployed" status are available. + */ +async function resolveDeployedHandlers( + baseDir: string, + onLog: (level: 'info' | 'warn' | 'error', msg: string) => void +): Promise { + const configIO = new ConfigIO({ baseDir }); + + if (!configIO.configExists('state') || !configIO.configExists('awsTargets')) { + return {}; + } + + try { + const deployedState = await configIO.readDeployedState(); + const awsTargets = await configIO.readAWSDeploymentTargets(); + + const targetName = Object.keys(deployedState.targets)[0]; + if (!targetName) return {}; + + const targetState = deployedState.targets[targetName]; + const targetConfig = awsTargets.find(t => t.name === targetName); + if (!targetConfig) return {}; + + const region = targetConfig.region; + const result: DeployedHandlers = {}; + + // Memory handlers + const memoryEntries = targetState?.resources?.memories ?? {}; + const memories = Object.entries(memoryEntries).map(([name, state]) => ({ + name, + memoryId: state.memoryId, + region, + })); + + if (memories.length > 0) { + onLog( + 'info', + `Memory browsing enabled for ${memories.length} deployed memory(ies): ${memories.map(m => m.name).join(', ')}` + ); + + 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({ + region: memory.region, + memoryId: memory.memoryId, + namespace, + memoryStrategyId: strategyId, + }); + }; + + 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({ + region: memory.region, + memoryId: memory.memoryId, + namespace, + searchQuery, + memoryStrategyId: strategyId, + }); + }; + } + + return result; + } catch (err) { + onLog('warn', `Could not resolve deployed resources: ${err instanceof Error ? err.message : String(err)}`); + return {}; + } +} + +export interface BrowserModeOptions { + workingDir: string; + project: AgentCoreProjectSpec; + port: number; + agentName?: string; + /** OTEL env vars to pass to dev servers (set by the dev command when collector is active) */ + otelEnvVars?: Record; + /** OTEL collector instance for local trace collection */ + collector?: OtelCollector; +} + +/** + * Standalone entry point for launching browser dev mode from the TUI. + * Handles all setup (project loading, OTEL collector, etc.) internally. + */ +export async function launchBrowserDev(): Promise { + const workingDir = getWorkingDirectory(); + const project = await loadProjectConfig(workingDir); + + if (!project?.runtimes || project.runtimes.length === 0) { + console.error('Error: No agents defined in project.'); + process.exit(1); + } + + const configRoot = findConfigRoot(workingDir); + const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); + const { collector, otelEnvVars } = await startOtelCollector(persistTracesDir); + + await runBrowserMode({ + workingDir, + project, + port: 8080, + otelEnvVars, + collector, + }); +} + +export async function runBrowserMode(opts: BrowserModeOptions): Promise { + const { workingDir, project, agentName, otelEnvVars = {}, collector } = opts; + + const configRoot = findConfigRoot(workingDir); + const { envVars } = await loadDevEnv(workingDir); + + const supportedAgents = getDevSupportedAgents(project); + + if (supportedAgents.length === 0) { + console.error('Error: No dev-supported agents found.'); + process.exit(1); + } + + if (agentName && !supportedAgents.some(a => a.name === agentName)) { + console.error(`Error: Agent "${agentName}" not found or does not support dev mode.`); + process.exit(1); + } + + const onLog = (level: 'info' | 'warn' | 'error', msg: string) => { + if (level === 'error') console.error(`Web UI: ${msg}`); + }; + + const mergedEnvVars = { ...envVars, ...otelEnvVars }; + + const agentInfoList: AgentInfo[] = supportedAgents.map(a => ({ + name: a.name, + buildType: a.build, + protocol: a.protocol ?? 'HTTP', + })); + + // Resolve deployed resources (memories, agents) so memory browsing and + // CloudWatch traces work in dev mode alongside local traces. + // Handlers re-resolve on each call so newly deployed memories are picked up. + const baseDir = configRoot ?? workingDir; + + await runWebUI({ + logLabel: 'dev', + onLog, + serverOptions: { + mode: 'dev', + agents: agentInfoList, + selectedAgent: agentName, + envVars: mergedEnvVars, + getEnvVars: async () => { + const { envVars: freshEnvVars } = await loadDevEnv(workingDir); + return { ...freshEnvVars, ...otelEnvVars }; + }, + configRoot: configRoot ?? undefined, + getDevConfig: async name => { + const freshProject = await loadProjectConfig(workingDir); + return getDevConfig(workingDir, freshProject, configRoot ?? undefined, name); + }, + reloadAgents: configRoot + ? async () => { + const freshProject = await loadProjectConfig(workingDir); + return getDevSupportedAgents(freshProject).map(a => ({ + name: a.name, + buildType: a.build, + protocol: a.protocol ?? 'HTTP', + })); + } + : undefined, + onListTraces: collector + ? (agentNameParam, startTime, endTime) => collector.listTraces(agentNameParam, startTime, endTime) + : undefined, + onGetTrace: collector ? (agentNameParam, traceId) => collector.getTraceSpans(agentNameParam, traceId) : undefined, + onListMemoryRecords: async (memoryName, namespace, strategyId) => { + const deployed = await resolveDeployedHandlers(baseDir, onLog); + if (!deployed.onListMemoryRecords) return { success: false, error: 'No deployed AgentCore Memory found' }; + return deployed.onListMemoryRecords(memoryName, namespace, strategyId); + }, + onRetrieveMemoryRecords: async (memoryName, namespace, searchQuery, strategyId) => { + const deployed = await resolveDeployedHandlers(baseDir, onLog); + if (!deployed.onRetrieveMemoryRecords) return { success: false, error: 'No deployed AgentCore Memory found' }; + return deployed.onRetrieveMemoryRecords(memoryName, namespace, searchQuery, strategyId); + }, + }, + }); +} diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 1f2ca8aff..eac485d8c 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -1,4 +1,4 @@ -import { findConfigRoot, getWorkingDirectory, readEnvFile } from '../../../lib'; +import { findConfigRoot, getWorkingDirectory } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { detectContainerRuntime } from '../../external-requirements'; import { ExecLogger } from '../../logging'; @@ -14,18 +14,20 @@ import { invokeAgentStreaming, invokeForProtocol, listMcpTools, + loadDevEnv, loadProjectConfig, } from '../../operations/dev'; -import { getGatewayEnvVars } from '../../operations/dev/gateway-env.js'; -import { getMemoryEnvVars } from '../../operations/dev/memory-env.js'; +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'; import { spawn } from 'child_process'; import { Text, render } from 'ink'; +import path from 'node:path'; import React from 'react'; // Alternate screen buffer - same as main TUI @@ -182,6 +184,9 @@ export const registerDev = (program: Command) => { (val: string, prev: string[]) => [...prev, val], [] as string[] ) + .option('-b, --no-browser', 'Use terminal TUI instead of web-based chat UI') + .option('--no-traces', 'Disable local OTEL trace collection') + .action(async (positionalPrompt: string | undefined, opts) => { try { const port = parseInt(opts.port, 10); @@ -242,6 +247,11 @@ export const registerDev = (program: Command) => { await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers); } else if (protocol === 'A2A') { await invokeA2ADevServer(invokePort, invokePrompt, headers); + } else if (protocol === 'AGUI') { + for await (const chunk of invokeForProtocol('AGUI', { port: invokePort, message: invokePrompt, headers })) { + process.stdout.write(chunk); + } + process.stdout.write('\n'); } else { await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers); } @@ -279,6 +289,19 @@ export const registerDev = (program: Command) => { process.exit(1); } + // Start local OTEL collector so agent traces are captured in dev mode. + // Persists traces to .cli/traces/ so they survive dev server restarts. + const configRoot = findConfigRoot(workingDir); + let otelEnvVars: Record = {}; + let collector: OtelCollector | undefined; + + if (opts.traces !== false) { + const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); + const otelResult = await startOtelCollector(persistTracesDir); + collector = otelResult.collector; + otelEnvVars = otelResult.otelEnvVars; + } + // If --logs provided, run non-interactive mode if (opts.logs) { // Require --agent if multiple agents @@ -290,12 +313,8 @@ export const registerDev = (program: Command) => { } const agentName = opts.runtime ?? project.runtimes[0]?.name; - const configRoot = findConfigRoot(workingDir); - const envVars = configRoot ? await readEnvFile(configRoot) : {}; - const gatewayEnvVars = await getGatewayEnvVars(); - const memoryEnvVars = await getMemoryEnvVars(); - // Deployed-state env vars go first, .env.local overrides take precedence - const mergedEnvVars = { ...gatewayEnvVars, ...memoryEnvVars, ...envVars }; + const { envVars } = await loadDevEnv(workingDir); + const mergedEnvVars = { ...envVars, ...otelEnvVars }; const config = getDevConfig(workingDir, project, configRoot ?? undefined, agentName); if (!config) { @@ -353,6 +372,7 @@ export const registerDev = (program: Command) => { // Handle Ctrl+C — use server.kill() for proper container cleanup process.on('SIGINT', () => { console.log('\nStopping server...'); + collector?.stop(); server.kill(); }); @@ -361,33 +381,48 @@ export const registerDev = (program: Command) => { await new Promise(() => {}); } - // Enter alternate screen buffer for fullscreen mode - process.stdout.write(ENTER_ALT_SCREEN); - - const exitAltScreen = () => { - process.stdout.write(EXIT_ALT_SCREEN); - process.stdout.write(SHOW_CURSOR); - }; - - const { DevScreen } = await import('../../tui/screens/dev/DevScreen'); - const { unmount, waitUntilExit } = render( - - { - exitAltScreen(); - unmount(); - process.exit(0); - }} - workingDir={workingDir} - port={port} - agentName={opts.runtime} - headers={headers} - /> - - ); - - await waitUntilExit(); - exitAltScreen(); + // 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); + + const exitAltScreen = () => { + process.stdout.write(EXIT_ALT_SCREEN); + process.stdout.write(SHOW_CURSOR); + }; + + const { DevScreen } = await import('../../tui/screens/dev/DevScreen'); + const { unmount, waitUntilExit } = render( + + { + exitAltScreen(); + unmount(); + process.exit(0); + }} + workingDir={workingDir} + port={port} + agentName={opts.runtime} + headers={headers} + /> + + ); + + await waitUntilExit(); + exitAltScreen(); + return; + } + + // Default: launch web UI in browser + await runBrowserMode({ + workingDir, + project, + port, + agentName: opts.runtime, + otelEnvVars, + collector, + }); } catch (error) { render(Error: {getErrorMessage(error)}); process.exit(1); 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 c0bdc337f..a57685b72 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -8,6 +8,7 @@ import type { Memory, } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; +import { arnPrefix } from '../../aws/partition'; import { ExecLogger } from '../../logging'; import { setupPythonProject } from '../../operations/python/setup'; import { executeCdkImportPipeline } from './import-pipeline'; @@ -41,7 +42,7 @@ function toAgentEnvSpec(agent: ParsedStarterToolkitConfig['agents'][0]): AgentEn runtimeVersion: (agent.runtimeVersion ?? 'PYTHON_3_12') as any, protocol: agent.protocol, networkMode: agent.networkMode, - instrumentation: { enableOtel: agent.enableOtel }, + instrumentation: { enableOtel: agent.protocol === 'MCP' ? false : agent.enableOtel }, }; /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ @@ -77,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, }; } @@ -521,7 +522,7 @@ export async function handleImport(options: ImportOptions): Promise m.physicalMemoryId) @@ -531,7 +532,7 @@ export async function handleImport(options: ImportOptions): Promise { 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/constants.ts b/src/cli/commands/import/constants.ts index 93c25f902..9de391f55 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -51,4 +51,5 @@ export const RUNTIME_TYPE_MAP: Record = { PYTHON_3_11: 'PYTHON_3_11', PYTHON_3_12: 'PYTHON_3_12', PYTHON_3_13: 'PYTHON_3_13', + PYTHON_3_14: 'PYTHON_3_14', }; 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/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index 298ea45fd..99935ecdf 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -5,6 +5,7 @@ import { listAllAgentRuntimes, listAllOnlineEvaluationConfigs, } from '../../aws/agentcore-control'; +import { arnPrefix } from '../../aws/partition'; import { ANSI } from './constants'; import { failResult, findResourceInDeployedState, parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; @@ -52,7 +53,7 @@ export function toOnlineEvalConfigSpec( * since evaluators locked by an online eval config cannot be CFN-imported. */ function buildEvaluatorArns(evaluatorIds: string[], region: string, account: string): string[] { - return evaluatorIds.map(id => `arn:aws:bedrock-agentcore:${region}:${account}:evaluator/${id}`); + return evaluatorIds.map(id => `${arnPrefix(region)}:bedrock-agentcore:${region}:${account}:evaluator/${id}`); } /** diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index d224870ec..2b825812c 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -130,10 +130,10 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis // Validate ARN format early if provided if ( arn && - !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.test(arn) + !/^arn:[^:]+:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.test(arn) ) { throw new Error( - `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` + `Not a valid ARN: "${arn}".\nExpected format: arn::bedrock-agentcore:::/` ); } @@ -146,7 +146,7 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis ); } - const arnMatch = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):/.exec(arn); + const arnMatch = /^arn:[^:]+:bedrock-agentcore:([^:]+):([^:]+):/.exec(arn); if (!arnMatch) { throw new Error( 'No deployment targets found in project and could not parse region/account from ARN.\nRun `agentcore deploy` first to set up a target, then re-run import.' @@ -210,7 +210,7 @@ export interface ParsedArn { } const ARN_PATTERN = - /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; + /^arn:[^:]+:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; /** Unified config for each importable resource type — ARN mapping, deployed state keys. */ const RESOURCE_TYPE_CONFIG: Record< @@ -244,7 +244,7 @@ export function parseAndValidateArn( const expectedArnType = RESOURCE_TYPE_CONFIG[expectedResourceType].arnType; if (!match) { throw new Error( - `Invalid ARN format: "${arn}". Expected format: arn:aws:bedrock-agentcore:::${expectedArnType}/` + `Invalid ARN format: "${arn}". Expected format: arn::bedrock-agentcore:::${expectedArnType}/` ); } diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index eab11c0a8..c2fa5d354 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -19,7 +19,7 @@ export interface ParsedStarterToolkitAgent { sourcePath?: string; networkMode: 'PUBLIC' | 'VPC'; networkConfig?: { subnets: string[]; securityGroups: string[] }; - protocol: 'HTTP' | 'MCP' | 'A2A'; + protocol: 'HTTP' | 'MCP' | 'A2A' | 'AGUI'; enableOtel: boolean; /** Physical agent runtime ID from the starter toolkit deployment */ physicalAgentId?: string; diff --git a/src/cli/commands/import/yaml-parser.ts b/src/cli/commands/import/yaml-parser.ts index 14bf1c519..3002416d7 100644 --- a/src/cli/commands/import/yaml-parser.ts +++ b/src/cli/commands/import/yaml-parser.ts @@ -1,4 +1,5 @@ import type { AuthorizerConfig, CustomClaimValidation, RuntimeAuthorizerType } from '../../../schema'; +import { ProtocolModeSchema } from '../../../schema'; import { RUNTIME_TYPE_MAP } from './constants'; import type { ParsedStarterToolkitAgent, @@ -199,7 +200,9 @@ export function parseStarterToolkitYaml(filePath: string): ParsedStarterToolkitC const networkModeConfig = networkConfig?.network_mode_config as Record | undefined; // Map protocol - const protocol = String((protocolConfig?.server_protocol as string) ?? 'HTTP') as 'HTTP' | 'MCP' | 'A2A'; + const protocolRaw = String((protocolConfig?.server_protocol as string) ?? 'HTTP'); + const protocolParsed = ProtocolModeSchema.safeParse(protocolRaw); + const protocol = protocolParsed.success ? protocolParsed.data : ('HTTP' as const); agents.push({ name: String((agentConfig.name as string) ?? agentKey), 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 449738fcd..77d7fdccd 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -1,10 +1,12 @@ import { ConfigIO } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; import { + buildAguiRunInput, executeBashCommand, invokeA2ARuntime, invokeAgentRuntime, invokeAgentRuntimeStreaming, + invokeAguiRuntime, mcpCallTool, mcpInitSession, mcpListTools, @@ -12,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 { @@ -112,18 +115,27 @@ 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({ agentName: agentSpec.name, runtimeArn: agentState.runtimeArn, region: targetConfig.region, + sessionId: options.sessionId, }); const command = options.prompt; if (!command) { return { success: false, error: '--exec requires a command (prompt)' }; } - logger.logPrompt(command, undefined, options.userId); + logger.logPrompt(command, options.sessionId, options.userId); try { const result = await executeBashCommand({ @@ -294,6 +306,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption region: targetConfig.region, runtimeArn: agentState.runtimeArn, userId: options.userId, + sessionId: options.sessionId, headers: options.headers, }, options.prompt @@ -319,14 +332,70 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } } + // AGUI protocol handling — send RunAgentInput via InvokeAgentRuntime, stream text + if (agentSpec.protocol === 'AGUI') { + const logger = new InvokeLogger({ + agentName: agentSpec.name, + runtimeArn: agentState.runtimeArn, + region: targetConfig.region, + }); + + try { + const aguiInput = buildAguiRunInput(options.prompt, options.sessionId); + logger.logPrompt(options.prompt, undefined, options.userId); + + const aguiResult = await invokeAguiRuntime( + { + region: targetConfig.region, + runtimeArn: agentState.runtimeArn, + sessionId: options.sessionId, + userId: options.userId, + logger, + headers: options.headers, + bearerToken: options.bearerToken, + }, + aguiInput + ); + let response = ''; + let hasError = false; + for await (const chunk of aguiResult.textStream) { + response += chunk; + if (chunk.startsWith('Error: ')) { + hasError = true; + } + if (options.stream) { + process.stdout.write(chunk); + } + } + if (options.stream) { + process.stdout.write('\n'); + } + + logger.logResponse(response); + + return { + success: !hasError, + agentName: agentSpec.name, + targetName: selectedTargetName, + response, + sessionId: aguiResult.sessionId, + logFilePath: logger.logFilePath, + }; + } catch (err) { + logger.logError(err, 'AGUI invoke failed'); + return { success: false, error: `AGUI invoke failed: ${err instanceof Error ? err.message : String(err)}` }; + } + } + // Create logger for this invocation const logger = new InvokeLogger({ agentName: agentSpec.name, runtimeArn: agentState.runtimeArn, region: targetConfig.region, + sessionId: options.sessionId, }); - logger.logPrompt(options.prompt, undefined, options.userId); + logger.logPrompt(options.prompt, options.sessionId, options.userId); if (options.stream) { // Streaming mode @@ -356,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) { @@ -382,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 c808aea53..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,10 +199,11 @@ export const registerInvoke = (program: Command) => { }); } else { // No CLI options - interactive TUI mode (headers still passed if provided) - const { waitUntilExit } = render( + requireTTY(); + const { waitUntilExit, unmount } = render( process.exit(0)} + onExit={() => unmount()} initialSessionId={cliOptions.sessionId} initialUserId={cliOptions.userId} initialHeaders={headers} diff --git a/src/cli/commands/invoke/resolve-prompt.ts b/src/cli/commands/invoke/resolve-prompt.ts new file mode 100644 index 000000000..810395b27 --- /dev/null +++ b/src/cli/commands/invoke/resolve-prompt.ts @@ -0,0 +1,70 @@ +import { readFile } from 'node:fs/promises'; + +export interface PromptSources { + /** Value from --prompt flag */ + flag?: string; + /** Value from positional argument */ + positional?: string; + /** Path from --prompt-file flag */ + file?: string; + /** True when stdin is piped (not a TTY) */ + stdinPiped: boolean; +} + +export interface ResolvedPrompt { + success: boolean; + prompt?: string; + error?: string; +} + +async function readPromptFile(path: string): Promise { + 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 e3601da47..05e532688 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'; @@ -79,6 +79,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[]; @@ -88,6 +92,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))); @@ -101,16 +106,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, }); } } @@ -132,7 +141,9 @@ function buildGatewayInvocationUrl( gwState.gatewayUrl ?? (() => { const region = gwState.gatewayArn.split(':')[3]; - return region ? `https://${gwState.gatewayId}.gateway.bedrock-agentcore.${region}.amazonaws.com` : undefined; + 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); @@ -257,8 +268,34 @@ export function computeResourceStatuses( 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) { + 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 5a689ee01..506ad10ec 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', @@ -60,7 +61,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, config-bundle, ab-test)' + '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') @@ -137,6 +138,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'); @@ -158,15 +160,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/status/constants.ts b/src/cli/commands/status/constants.ts index e9b047d5d..f43522b43 100644 --- a/src/cli/commands/status/constants.ts +++ b/src/cli/commands/status/constants.ts @@ -1,3 +1,4 @@ +import { serviceEndpoint } from '../../aws/partition'; import { STATUS_COLORS } from '../../tui/theme'; export type ResourceDeploymentState = 'deployed' | 'local-only' | 'pending-removal'; @@ -16,5 +17,5 @@ export const DEPLOYMENT_STATE_LABELS: Record = export function buildRuntimeInvocationUrl(region: string, runtimeArn: string): string { const encodedArn = encodeURIComponent(runtimeArn); - return `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/${encodedArn}/invocations`; + return `https://${serviceEndpoint('bedrock-agentcore', region)}/runtimes/${encodedArn}/invocations`; } 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/external-requirements/__tests__/detect.test.ts b/src/cli/external-requirements/__tests__/detect.test.ts index 5adcc7865..dfaa1894e 100644 --- a/src/cli/external-requirements/__tests__/detect.test.ts +++ b/src/cli/external-requirements/__tests__/detect.test.ts @@ -1,4 +1,4 @@ -import { detectContainerRuntime, getStartHint, requireContainerRuntime } from '../detect.js'; +import { detectContainerRuntime, requireContainerRuntime } from '../detect.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockCheckSubprocess, mockRunSubprocessCapture } = vi.hoisted(() => ({ @@ -8,11 +8,6 @@ const { mockCheckSubprocess, mockRunSubprocessCapture } = vi.hoisted(() => ({ vi.mock('../../../lib', () => ({ CONTAINER_RUNTIMES: ['docker', 'podman', 'finch'], - START_HINTS: { - docker: 'Start Docker Desktop or run: sudo systemctl start docker', - podman: 'Run: podman machine start', - finch: 'Run: finch vm init && finch vm start', - }, checkSubprocess: mockCheckSubprocess, runSubprocessCapture: mockRunSubprocessCapture, isWindows: false, @@ -20,38 +15,16 @@ vi.mock('../../../lib', () => ({ afterEach(() => vi.clearAllMocks()); -describe('getStartHint', () => { - it('formats a single runtime hint', () => { - const result = getStartHint(['docker']); - expect(result).toBe(' docker: Start Docker Desktop or run: sudo systemctl start docker'); - }); - - it('joins multiple runtime hints with newlines', () => { - const result = getStartHint(['docker', 'finch']); - expect(result).toBe( - ' docker: Start Docker Desktop or run: sudo systemctl start docker\n' + - ' finch: Run: finch vm init && finch vm start' - ); - }); - - it('returns empty string for empty array', () => { - const result = getStartHint([]); - expect(result).toBe(''); - }); -}); - describe('detectContainerRuntime', () => { - it('returns docker when docker is installed and ready', async () => { + it('returns docker when docker is installed', async () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); - if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); const result = await detectContainerRuntime(); expect(result.runtime).toEqual({ runtime: 'docker', binary: 'docker', version: 'Docker version 24.0.0' }); - expect(result.notReadyRuntimes).toEqual([]); }); it('falls back to podman when docker not installed', async () => { @@ -63,7 +36,6 @@ describe('detectContainerRuntime', () => { mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => { if (bin === 'podman' && args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' }); - if (bin === 'podman' && args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -71,44 +43,11 @@ describe('detectContainerRuntime', () => { expect(result.runtime).toEqual({ runtime: 'podman', binary: 'podman', version: 'podman version 4.5.0' }); }); - it('reports docker as notReady when installed but daemon not running', async () => { - // docker exists and --version works, but info fails - mockCheckSubprocess.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === 'docker') return Promise.resolve(true); - return Promise.resolve(false); - }); - mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => { - if (bin === 'docker' && args[0] === '--version') - return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); - if (bin === 'docker' && args[0] === 'info') - return Promise.resolve({ code: 1, stdout: '', stderr: 'Cannot connect to the Docker daemon' }); - return Promise.resolve({ code: 1, stdout: '', stderr: '' }); - }); - - const result = await detectContainerRuntime(); - expect(result.runtime).toBeNull(); - expect(result.notReadyRuntimes).toContain('docker'); - }); - it('returns null runtime when nothing is installed', async () => { mockCheckSubprocess.mockResolvedValue(false); const result = await detectContainerRuntime(); expect(result.runtime).toBeNull(); - expect(result.notReadyRuntimes).toEqual([]); - }); - - it('returns null with notReadyRuntimes when installed but not ready', async () => { - mockCheckSubprocess.mockResolvedValue(true); - mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { - if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'v1.0.0\n', stderr: '' }); - if (args[0] === 'info') return Promise.resolve({ code: 1, stdout: '', stderr: 'not running' }); - return Promise.resolve({ code: 1, stdout: '', stderr: '' }); - }); - - const result = await detectContainerRuntime(); - expect(result.runtime).toBeNull(); - expect(result.notReadyRuntimes).toEqual(['docker', 'podman', 'finch']); }); it('skips runtime when --version check fails', async () => { @@ -118,7 +57,6 @@ describe('detectContainerRuntime', () => { if (bin === 'docker' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' }); if (bin === 'podman' && args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' }); - if (bin === 'podman' && args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); // finch --version also fails if (bin === 'finch' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); @@ -126,7 +64,6 @@ describe('detectContainerRuntime', () => { const result = await detectContainerRuntime(); expect(result.runtime).toEqual({ runtime: 'podman', binary: 'podman', version: 'podman version 4.5.0' }); - expect(result.notReadyRuntimes).toEqual([]); }); it('extracts first line of --version output as version string', async () => { @@ -134,7 +71,6 @@ describe('detectContainerRuntime', () => { mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\nExtra info line\n', stderr: '' }); - if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -146,7 +82,6 @@ describe('detectContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); - if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -154,6 +89,22 @@ describe('detectContainerRuntime', () => { // ''.trim().split('\n')[0] returns '' (not undefined), so ?? 'unknown' doesn't trigger expect(result.runtime?.version).toBe(''); }); + + it('does not call docker info to check daemon status', async () => { + mockCheckSubprocess.mockResolvedValue(true); + mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { + if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); + return Promise.resolve({ code: 1, stdout: '', stderr: '' }); + }); + + await detectContainerRuntime(); + + // Verify 'info' was never called — this is the key behavioral change + const infoCalls = mockRunSubprocessCapture.mock.calls.filter( + (call: unknown[]) => (call[1] as string[])[0] === 'info' + ); + expect(infoCalls).toHaveLength(0); + }); }); describe('requireContainerRuntime', () => { @@ -161,7 +112,6 @@ describe('requireContainerRuntime', () => { mockCheckSubprocess.mockResolvedValue(true); mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' }); - if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' }); return Promise.resolve({ code: 1, stdout: '', stderr: '' }); }); @@ -169,22 +119,10 @@ describe('requireContainerRuntime', () => { expect(result).toEqual({ runtime: 'docker', binary: 'docker', version: 'Docker version 24.0.0' }); }); - it('throws with install links when no runtime found and none notReady', async () => { + it('throws with install links when no runtime found', async () => { mockCheckSubprocess.mockResolvedValue(false); await expect(requireContainerRuntime()).rejects.toThrow('No container runtime found'); await expect(requireContainerRuntime()).rejects.toThrow('https://docker.com'); }); - - it('throws with start hints when runtimes installed but not ready', async () => { - mockCheckSubprocess.mockResolvedValue(true); - mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => { - if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'v1.0.0\n', stderr: '' }); - if (args[0] === 'info') return Promise.resolve({ code: 1, stdout: '', stderr: 'not running' }); - return Promise.resolve({ code: 1, stdout: '', stderr: '' }); - }); - - await expect(requireContainerRuntime()).rejects.toThrow('not ready'); - await expect(requireContainerRuntime()).rejects.toThrow('Start a runtime'); - }); }); diff --git a/src/cli/external-requirements/detect.ts b/src/cli/external-requirements/detect.ts index 0efd9a48a..054a4d82b 100644 --- a/src/cli/external-requirements/detect.ts +++ b/src/cli/external-requirements/detect.ts @@ -2,7 +2,7 @@ * Container runtime detection. * Detects Docker, Podman, or Finch for container operations. */ -import { CONTAINER_RUNTIMES, type ContainerRuntime, START_HINTS } from '../../lib'; +import { CONTAINER_RUNTIMES, type ContainerRuntime } from '../../lib'; import { checkSubprocess, isWindows, runSubprocessCapture } from '../../lib'; export type { ContainerRuntime } from '../../lib'; @@ -14,26 +14,19 @@ export interface ContainerRuntimeInfo { } export interface DetectionResult { - /** The first ready runtime, or null if none are ready. */ + /** The first available runtime, or null if none are installed. */ runtime: ContainerRuntimeInfo | null; - /** Runtimes that are installed but not ready (e.g., VM not started). */ - notReadyRuntimes: ContainerRuntime[]; -} - -/** - * Build a user-friendly hint for runtimes that are installed but not ready. - */ -export function getStartHint(runtimes: ContainerRuntime[]): string { - return runtimes.map(r => ` ${r}: ${START_HINTS[r]}`).join('\n'); } /** * Detect available container runtime. - * Checks docker, podman, finch in order; returns the first that is installed and usable, - * plus a list of runtimes that are installed but not ready. + * Checks docker, podman, finch in order; returns the first that is installed. + * Does not probe the daemon (e.g., `docker info`) — that would require socket + * access and can trigger OS password prompts on systems where the user is not + * in the docker group. Actual daemon availability is validated when the runtime + * is used (build, run, etc.). */ export async function detectContainerRuntime(): Promise { - const notReadyRuntimes: ContainerRuntime[] = []; for (const runtime of CONTAINER_RUNTIMES) { // Check if binary exists const exists = isWindows ? await checkSubprocess('where', [runtime]) : await checkSubprocess('which', [runtime]); @@ -43,17 +36,10 @@ export async function detectContainerRuntime(): Promise { const result = await runSubprocessCapture(runtime, ['--version']); if (result.code !== 0) continue; - // Verify the runtime is actually usable (e.g., finch VM initialized, docker daemon running) - const infoResult = await runSubprocessCapture(runtime, ['info']); - if (infoResult.code !== 0) { - notReadyRuntimes.push(runtime); - continue; - } - const version = result.stdout.trim().split('\n')[0] ?? 'unknown'; - return { runtime: { runtime, binary: runtime, version }, notReadyRuntimes }; + return { runtime: { runtime, binary: runtime, version } }; } - return { runtime: null, notReadyRuntimes }; + return { runtime: null }; } /** @@ -61,13 +47,8 @@ export async function detectContainerRuntime(): Promise { * Used by commands that require a container runtime (e.g., dev). */ export async function requireContainerRuntime(): Promise { - const { runtime, notReadyRuntimes } = await detectContainerRuntime(); + const { runtime } = await detectContainerRuntime(); if (!runtime) { - if (notReadyRuntimes.length > 0) { - throw new Error( - `Found ${notReadyRuntimes.join(', ')} but not ready. Start a runtime:\n${getStartHint(notReadyRuntimes)}` - ); - } throw new Error( 'No container runtime found. Install Docker (https://docker.com), ' + 'Podman (https://podman.io), or Finch (https://runfinch.com).' diff --git a/src/cli/external-requirements/index.ts b/src/cli/external-requirements/index.ts index 5ff5badcd..3ebce3e1a 100644 --- a/src/cli/external-requirements/index.ts +++ b/src/cli/external-requirements/index.ts @@ -32,7 +32,6 @@ export { export { detectContainerRuntime, requireContainerRuntime, - getStartHint, type ContainerRuntime, type ContainerRuntimeInfo, type DetectionResult, diff --git a/src/cli/logging/__tests__/invoke-logger-session-id.test.ts b/src/cli/logging/__tests__/invoke-logger-session-id.test.ts new file mode 100644 index 000000000..30452fb62 --- /dev/null +++ b/src/cli/logging/__tests__/invoke-logger-session-id.test.ts @@ -0,0 +1,96 @@ +import { InvokeLogger } from '../invoke-logger.js'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../lib', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + findConfigRoot: () => tempDir, + }; +}); + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'invoke-logger-test-')); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +function readLog(logger: InvokeLogger): string { + return readFileSync(logger.logFilePath, 'utf-8'); +} + +describe('InvokeLogger session ID', () => { + it('writes session ID in header when provided via constructor', () => { + const logger = new InvokeLogger({ + agentName: 'testAgent', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456:runtime/test', + region: 'us-east-1', + sessionId: 'my-session-123', + }); + + const content = readLog(logger); + expect(content).toContain('Session ID: my-session-123'); + expect(content).not.toContain('Session ID: none'); + }); + + it('writes "none" when session ID is not provided', () => { + const logger = new InvokeLogger({ + agentName: 'testAgent', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456:runtime/test', + region: 'us-east-1', + }); + + const content = readLog(logger); + expect(content).toContain('Session ID: none'); + }); + + it('includes session ID in logPrompt output when passed as argument', () => { + const logger = new InvokeLogger({ + agentName: 'testAgent', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456:runtime/test', + region: 'us-east-1', + sessionId: 'my-session-456', + }); + + logger.logPrompt('hello world', 'my-session-456', 'user-1'); + + const content = readLog(logger); + expect(content).toContain('Session: my-session-456'); + expect(content).toContain('"sessionId": "my-session-456"'); + }); + + it('logPrompt falls back to constructor sessionId when argument is undefined', () => { + const logger = new InvokeLogger({ + agentName: 'testAgent', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456:runtime/test', + region: 'us-east-1', + sessionId: 'constructor-session', + }); + + logger.logPrompt('hello world', undefined, 'user-1'); + + const content = readLog(logger); + expect(content).toContain('Session: constructor-session'); + expect(content).toContain('"sessionId": "constructor-session"'); + }); + + it('logPrompt shows "none" when no session ID anywhere', () => { + const logger = new InvokeLogger({ + agentName: 'testAgent', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456:runtime/test', + region: 'us-east-1', + }); + + logger.logPrompt('hello world'); + + const content = readLog(logger); + expect(content).toContain('Session: none'); + }); +}); diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index de85071b6..54f8aa0ba 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/agent/generate/__tests__/schema-mapper.test.ts b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts index 76b2de9f2..d30ae23fc 100644 --- a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts +++ b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts @@ -87,7 +87,7 @@ describe('mapGenerateConfigToAgent', () => { expect(result.name).toBe('TestProject'); expect(result.build).toBe('CodeZip'); expect(result.entrypoint).toBe('main.py'); - expect(result.runtimeVersion).toBe('PYTHON_3_13'); + expect(result.runtimeVersion).toBe('PYTHON_3_14'); expect(result.networkMode).toBe('PUBLIC'); expect(result.protocol).toBe('HTTP'); }); diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 322cca523..6d8ca15ab 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -147,6 +147,9 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { }, } : {}), + ...(config.sessionStorageMountPath && { + filesystemConfigurations: [{ sessionStorage: { mountPath: config.sessionStorageMountPath } }], + }), // MCP uses mcp.run() which is incompatible with the opentelemetry-instrument wrapper ...(protocol === 'MCP' && { instrumentation: { enableOtel: false } }), }; @@ -261,6 +264,7 @@ export async function mapGenerateConfigToRenderConfig( ): Promise { const isMcp = config.protocol === 'MCP'; const gatewayProviders = isMcp ? [] : await mapGatewaysToGatewayProviders(); + const enableOtel = !isMcp; return { name: config.projectName, @@ -278,5 +282,7 @@ export async function mapGenerateConfigToRenderConfig( gatewayAuthTypes: [...new Set(gatewayProviders.map(g => g.authType))], protocol: config.protocol, dockerfile: config.dockerfile, + sessionStorageMountPath: config.sessionStorageMountPath, + enableOtel, }; } diff --git a/src/cli/operations/agent/import/__tests__/translator.test.ts b/src/cli/operations/agent/import/__tests__/translator.test.ts index 4c5edc260..ea4f323b1 100644 --- a/src/cli/operations/agent/import/__tests__/translator.test.ts +++ b/src/cli/operations/agent/import/__tests__/translator.test.ts @@ -254,6 +254,7 @@ describe('generatePyprojectToml', () => { expect(result).toContain('langgraph'); expect(result).toContain('langchain_aws'); + expect(result).toContain('opentelemetry-instrumentation-langchain'); expect(result).not.toContain('strands-agents'); }); diff --git a/src/cli/operations/agent/import/base-translator.ts b/src/cli/operations/agent/import/base-translator.ts index 5056c6df9..e92277054 100644 --- a/src/cli/operations/agent/import/base-translator.ts +++ b/src/cli/operations/agent/import/base-translator.ts @@ -14,6 +14,7 @@ import type { KnowledgeBaseInfo, PromptConfiguration, } from '../../../aws/bedrock-import-types'; +import { arnPrefix } from '../../../aws/partition'; import type { MemoryOption } from '../../../tui/screens/generate/types'; export interface TranslatorOptions { @@ -373,7 +374,9 @@ memory_id = os.environ.get("MEMORY_ID", "") if (kb.knowledgeBaseArn) { kbArns.push(kb.knowledgeBaseArn); } else if (kb.knowledgeBaseId) { - kbArns.push(`arn:aws:bedrock:${this.agentRegion}:*:knowledge-base/${kb.knowledgeBaseId}`); + kbArns.push( + `${arnPrefix(this.agentRegion)}:bedrock:${this.agentRegion}:*:knowledge-base/${kb.knowledgeBaseId}` + ); } } diff --git a/src/cli/operations/agent/import/constants.ts b/src/cli/operations/agent/import/constants.ts index ce79e3814..ae9c21def 100644 --- a/src/cli/operations/agent/import/constants.ts +++ b/src/cli/operations/agent/import/constants.ts @@ -12,6 +12,7 @@ export const BEDROCK_REGIONS = [ { id: 'ap-south-1', title: 'Asia Pacific (Mumbai)' }, { id: 'ca-central-1', title: 'Canada (Central)' }, { id: 'sa-east-1', title: 'South America (Sao Paulo)' }, + { id: 'us-gov-west-1', title: 'GovCloud (US West)' }, ] as const; export const IMPORT_FRAMEWORK_OPTIONS = [ diff --git a/src/cli/operations/agent/import/index.ts b/src/cli/operations/agent/import/index.ts index 5b13c4c23..e49d9e3c0 100644 --- a/src/cli/operations/agent/import/index.ts +++ b/src/cli/operations/agent/import/index.ts @@ -31,6 +31,7 @@ export interface ExecuteImportAgentParams { jwtConfig?: JwtConfigOptions; idleTimeout?: number; maxLifetime?: number; + sessionStorageMountPath?: string; } export async function executeImportAgent( @@ -48,6 +49,7 @@ export async function executeImportAgent( jwtConfig, idleTimeout, maxLifetime, + sessionStorageMountPath, } = params; const projectRoot = dirname(configBaseDir); const agentPath = join(projectRoot, APP_DIR, name); @@ -98,6 +100,7 @@ export async function executeImportAgent( jwtConfig, idleRuntimeSessionTimeout: idleTimeout, maxLifetime, + sessionStorageMountPath, }; await writeAgentToProject(generateConfig, { configBaseDir }); diff --git a/src/cli/operations/agent/import/pyproject-generator.ts b/src/cli/operations/agent/import/pyproject-generator.ts index d602e9d18..f59a0e1fe 100644 --- a/src/cli/operations/agent/import/pyproject-generator.ts +++ b/src/cli/operations/agent/import/pyproject-generator.ts @@ -18,6 +18,7 @@ const LANGGRAPH_DEPS = [ 'langchain>=1.0.3', 'langchain_aws>=1.0.0', 'langchain-mcp-adapters>=0.1.11', + 'opentelemetry-instrumentation-langchain>=0.59.0', 'tiktoken==0.11.0', ]; diff --git a/src/cli/operations/deploy/teardown.ts b/src/cli/operations/deploy/teardown.ts index ddb7de5dc..7aaf59aaa 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -1,5 +1,6 @@ import { CONFIG_DIR, ConfigIO } from '../../../lib'; import type { AwsDeploymentTarget } from '../../../schema'; +import { withTargetRegion } from '../../aws'; import { deleteHttpGateway, deleteHttpGatewayTarget } from '../../aws/agentcore-http-gateways'; import { CdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib'; import { type DiscoveredStack, findStack } from '../../cloudformation/stack-discovery'; @@ -62,12 +63,18 @@ export async function destroyTarget(options: DestroyTargetOptions): Promise { + await toolkit.initialize(); + await toolkit.destroy({ + stacks: { + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, + patterns: [target.stack.stackName], + }, + }); }); // Clean up deployed-state.json after successful destroy 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 f0a91bd3f..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,12 +2,12 @@ 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', () => ({ spawn: (...args: unknown[]) => mockSpawn(...args), - spawnSync: vi.fn(() => ({ status: 0 })), + spawnSync: vi.fn(() => ({ status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') })), })); vi.mock('fs', () => ({ @@ -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__/container-dev-server.test.ts b/src/cli/operations/dev/__tests__/container-dev-server.test.ts index 30dbe43c4..e8510ce94 100644 --- a/src/cli/operations/dev/__tests__/container-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/container-dev-server.test.ts @@ -9,7 +9,6 @@ const mockSpawnSync = vi.fn(); const mockSpawn = vi.fn(); const mockExistsSync = vi.fn(); const mockDetectContainerRuntime = vi.fn(); -const mockGetStartHint = vi.fn(); const mockWaitForServerReady = vi.fn(); vi.mock('child_process', () => ({ @@ -29,7 +28,6 @@ vi.mock('os', () => ({ // Path is relative to this test file in __tests__/, so 3 levels up to reach cli/ vi.mock('../../../external-requirements/detect', () => ({ detectContainerRuntime: (...args: unknown[]) => mockDetectContainerRuntime(...args), - getStartHint: (...args: unknown[]) => mockGetStartHint(...args), })); vi.mock('../utils', async importOriginal => { @@ -71,7 +69,6 @@ function mockSuccessfulPrepare() { // Runtime detected mockDetectContainerRuntime.mockResolvedValue({ runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' }, - notReadyRuntimes: [], }); // Dockerfile exists (first call), ~/.aws exists (second call in getSpawnConfig) mockExistsSync.mockReturnValue(true); @@ -115,7 +112,6 @@ describe('ContainerDevServer', () => { it('returns null when no container runtime detected', async () => { mockDetectContainerRuntime.mockResolvedValue({ runtime: null, - notReadyRuntimes: [], }); const server = new ContainerDevServer(defaultConfig, defaultOptions); @@ -128,25 +124,9 @@ describe('ContainerDevServer', () => { ); }); - it('logs start hints when runtimes installed but not ready', async () => { - mockDetectContainerRuntime.mockResolvedValue({ - runtime: null, - notReadyRuntimes: ['docker', 'podman'], - }); - mockGetStartHint.mockReturnValue('Start Docker Desktop'); - - const server = new ContainerDevServer(defaultConfig, defaultOptions); - const result = await server.start(); - - expect(result).toBeNull(); - expect(mockCallbacks.onLog).toHaveBeenCalledWith('error', expect.stringContaining('docker, podman')); - expect(mockGetStartHint).toHaveBeenCalledWith(['docker', 'podman']); - }); - it('returns null when Dockerfile is missing', async () => { mockDetectContainerRuntime.mockResolvedValue({ runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' }, - notReadyRuntimes: [], }); mockExistsSync.mockReturnValue(false); @@ -175,7 +155,6 @@ describe('ContainerDevServer', () => { it('returns null when image build fails', async () => { mockDetectContainerRuntime.mockResolvedValue({ runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' }, - notReadyRuntimes: [], }); mockExistsSync.mockReturnValue(true); // rm succeeds (spawnSync) @@ -250,7 +229,6 @@ describe('ContainerDevServer', () => { it('streams build output lines at system level in real-time', async () => { mockDetectContainerRuntime.mockResolvedValue({ runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' }, - notReadyRuntimes: [], }); mockExistsSync.mockReturnValue(true); mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') }); // rm @@ -362,14 +340,22 @@ describe('ContainerDevServer', () => { expect(spawnArgs).toContain(`PORT=${CONTAINER_INTERNAL_PORT}`); }); - it('disables OpenTelemetry SDK to avoid missing-collector errors', async () => { + it('forwards OTEL env vars from caller to the container', async () => { mockSuccessfulPrepare(); - const server = new ContainerDevServer(defaultConfig, defaultOptions); + const options = { + ...defaultOptions, + envVars: { + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://127.0.0.1:4318', + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json', + }, + }; + const server = new ContainerDevServer(defaultConfig, options); await server.start(); const spawnArgs = getSpawnArgs(); - expect(spawnArgs).toContain('OTEL_SDK_DISABLED=true'); + expect(spawnArgs).toContain('OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:4318'); + expect(spawnArgs).toContain('OTEL_EXPORTER_OTLP_PROTOCOL=http/json'); }); it('forwards AWS env vars when present in process.env', async () => { @@ -437,7 +423,6 @@ describe('ContainerDevServer', () => { it('skips ~/.aws mount when directory does not exist', async () => { mockDetectContainerRuntime.mockResolvedValue({ runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' }, - notReadyRuntimes: [], }); // existsSync is called for: (1) Dockerfile in prepare(), (2) ~/.aws in getSpawnConfig() mockExistsSync.mockImplementation((path: string) => { 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/codezip-dev-server.ts b/src/cli/operations/dev/codezip-dev-server.ts index 4428493d9..31804a036 100644 --- a/src/cli/operations/dev/codezip-dev-server.ts +++ b/src/cli/operations/dev/codezip-dev-server.ts @@ -4,7 +4,7 @@ import { DevServer, type LogLevel, type SpawnConfig } from './dev-server'; import { convertEntrypointToModule } from './utils'; import { spawnSync } from 'child_process'; import { existsSync } from 'fs'; -import { join } from 'path'; +import { delimiter, join } from 'path'; /** * Ensures a Python virtual environment exists and has dependencies installed. @@ -67,6 +67,32 @@ function ensurePythonVenv( return true; } +/** + * Locate the directory containing OpenTelemetry's auto-instrumentation sitecustomize.py. + * When this directory is prepended to PYTHONPATH, Python will execute sitecustomize.py + * on startup, which bootstraps OTEL auto-instrumentation in every process. + */ +function findOtelSitecustomizeDir(venvPath: string): string | undefined { + // opentelemetry-instrument stores its sitecustomize.py at: + // /opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py + // We need the parent directory so Python finds the file as `sitecustomize.py`. + const result = spawnSync( + getVenvExecutable(venvPath, 'python'), + [ + '-c', + 'import opentelemetry.instrumentation.auto_instrumentation as m; import os; print(os.path.dirname(m.__file__))', + ], + { cwd: venvPath, stdio: 'pipe' } + ); + if (result.status === 0) { + const dir = result.stdout.toString().trim(); + if (dir && existsSync(join(dir, 'sitecustomize.py'))) { + return dir; + } + } + return undefined; +} + /** Dev server for CodeZip agents. Runs uvicorn (Python) or npx tsx (Node.js) locally. */ export class CodeZipDevServer extends DevServer { protected prepare(): Promise { @@ -80,10 +106,18 @@ export class CodeZipDevServer extends DevServer { protected getSpawnConfig(): SpawnConfig { const { module, directory, isPython, protocol } = this.config; const { port, envVars = {} } = this.options; - const env = { ...process.env, ...envVars, PORT: String(port), LOCAL_DEV: '1' }; + const env: Record = { ...process.env, ...envVars, PORT: String(port), LOCAL_DEV: '1' }; + + // FastMCP declares FASTMCP_PORT via pydantic BaseSettings (env_prefix="FASTMCP_"), + // but its __init__ passes port=8000 as an init kwarg to Settings(), which takes + // priority over env vars in pydantic v2. So this env var is currently ineffective — + // the agent always binds to 8000. We still set it for forward compatibility in case + // a future MCP SDK release fixes the override. The dev server targets port 8000. + if (protocol === 'MCP') { + env.FASTMCP_PORT = String(port); + } if (!isPython) { - // Node.js path (unchanged) return { cmd: 'npx', args: ['tsx', 'watch', (module.split(':')[0] ?? module).replace(/\./g, '/') + '.ts'], @@ -94,8 +128,25 @@ export class CodeZipDevServer extends DevServer { const venvDir = join(directory, '.venv'); + // Enable OTEL auto-instrumentation via sitecustomize.py injection. + // We can't use `opentelemetry-instrument` as a wrapper because uvicorn's + // --reload runs two processes: a reloader (parent) that watches files, and + // a worker (child) that actually serves requests. The wrapper only instruments + // the reloader — when it respawns the worker on file changes, the new worker + // is a fresh Python process with no tracing, so requests go untraced. + // Instead, we prepend the OTEL sitecustomize.py directory to PYTHONPATH. + // Python executes sitecustomize.py automatically on startup in every process, + // so both the reloader and every worker it spawns get instrumented. + const otelSitecustomizeDir = findOtelSitecustomizeDir(venvDir); + if (envVars.OTEL_EXPORTER_OTLP_ENDPOINT && otelSitecustomizeDir) { + const existingPythonPath = env.PYTHONPATH ?? ''; + env.PYTHONPATH = existingPythonPath + ? `${otelSitecustomizeDir}${delimiter}${existingPythonPath}` + : otelSitecustomizeDir; + } + if (protocol !== 'HTTP') { - // MCP/A2A: run python main.py directly (no module-level ASGI app) + // MCP/A2A/AGUI: run python main.py directly (no module-level ASGI app) const python = getVenvExecutable(venvDir, 'python'); const entryFile = module.split(':')[0] ?? module; return { cmd: python, args: [entryFile], cwd: directory, env }; diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index 1159f7bbe..10ef21b3f 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -1,6 +1,6 @@ import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../../../lib'; import { getUvBuildArgs } from '../../../lib/packaging/build-args'; -import { detectContainerRuntime, getStartHint } from '../../external-requirements/detect'; +import { detectContainerRuntime } from '../../external-requirements/detect'; import { DevServer, type LogLevel, type SpawnConfig } from './dev-server'; import { waitForServerReady } from './utils'; import { type ChildProcess, spawn, spawnSync } from 'child_process'; @@ -58,16 +58,9 @@ export class ContainerDevServer extends DevServer { const { onLog } = this.options.callbacks; // 1. Detect container runtime - const { runtime, notReadyRuntimes } = await detectContainerRuntime(); + const { runtime } = await detectContainerRuntime(); if (!runtime) { - if (notReadyRuntimes.length > 0) { - onLog( - 'error', - `Found ${notReadyRuntimes.join(', ')} but not ready. Start a runtime:\n${getStartHint(notReadyRuntimes)}` - ); - } else { - onLog('error', 'No container runtime found. Install Docker, Podman, or Finch.'); - } + onLog('error', 'No container runtime found. Install Docker, Podman, or Finch.'); return false; } this.runtimeBinary = runtime.binary; @@ -163,15 +156,24 @@ export class ContainerDevServer extends DevServer { : {}; // Environment variables: AWS creds + config paths + user env + container-specific overrides. - // Disable OpenTelemetry SDK — no collector is running locally, and the OTEL - // exporter connection errors would crash or pollute the dev server output. + // OTEL env vars (endpoint + protocol) are passed via envVars from the caller, + // pointing the agent's OTEL exporter at the local collector. + // Inside a container, 127.0.0.1 refers to the container itself — rewrite to + // host.docker.internal so the exporter can reach the host-side collector. + const containerEnvVars = { ...envVars }; + if (containerEnvVars.OTEL_EXPORTER_OTLP_ENDPOINT) { + containerEnvVars.OTEL_EXPORTER_OTLP_ENDPOINT = containerEnvVars.OTEL_EXPORTER_OTLP_ENDPOINT.replace( + '127.0.0.1', + 'host.docker.internal' + ).replace('localhost', 'host.docker.internal'); + } + const envArgs = Object.entries({ ...awsEnvVars, ...awsConfigEnv, - ...envVars, + ...containerEnvVars, LOCAL_DEV: '1', PORT: String(CONTAINER_INTERNAL_PORT), - OTEL_SDK_DISABLED: 'true', }).flatMap(([k, v]) => ['-e', `${k}=${v}`]); return { diff --git a/src/cli/operations/dev/index.ts b/src/cli/operations/dev/index.ts index 5c11b692f..2522ef21c 100644 --- a/src/cli/operations/dev/index.ts +++ b/src/cli/operations/dev/index.ts @@ -17,4 +17,8 @@ export { invokeA2AStreaming, fetchA2AAgentCard, type A2AAgentCard } from './invo export { listMcpTools, callMcpTool, type McpTool, type McpToolsResult } from './invoke-mcp'; +export { invokeAguiStreaming } from './invoke-agui'; + export { getEndpointUrl, formatMcpToolList } from './utils'; + +export { loadDevEnv, type DevEnv } from './load-dev-env'; diff --git a/src/cli/operations/dev/invoke-a2a.ts b/src/cli/operations/dev/invoke-a2a.ts index 54ffebbc2..ec2a192ea 100644 --- a/src/cli/operations/dev/invoke-a2a.ts +++ b/src/cli/operations/dev/invoke-a2a.ts @@ -215,7 +215,7 @@ function handleSSEEvent(event: Record, onStatus?: (status: stri } /** Check if an event (possibly wrapped in JSON-RPC envelope) is a status-update */ -function isStatusUpdateEvent(event: Record): boolean { +export function isStatusUpdateEvent(event: Record): boolean { const target = (event.result as Record) ?? event; return target.kind === 'status-update'; } @@ -232,7 +232,7 @@ function isStatusUpdateEvent(event: Record): boolean { * When `streamedFromStatus` is true, artifact-update text is skipped because * the same content was already streamed incrementally via status-update events. */ -function extractSSEEventText(event: Record, streamedFromStatus = false): string | null { +export function extractSSEEventText(event: Record, streamedFromStatus = false): string | null { // Unwrap JSON-RPC result envelope if present const target = (event.result as Record) ?? event; const kind = target.kind as string | undefined; @@ -260,7 +260,7 @@ function extractSSEEventText(event: Record, streamedFromStatus } /** Extract text from a full Task result (has artifacts array and/or status) */ -function extractTaskText(result: Record): string | null { +export function extractTaskText(result: Record): string | null { // Try artifacts first const artifacts = result.artifacts as { parts?: { kind?: string; type?: string; text?: string }[] }[] | undefined; if (artifacts) { diff --git a/src/cli/operations/dev/invoke-agui.ts b/src/cli/operations/dev/invoke-agui.ts new file mode 100644 index 000000000..0349e5271 --- /dev/null +++ b/src/cli/operations/dev/invoke-agui.ts @@ -0,0 +1,153 @@ +import { parseAguiSSEStream } from '../../aws/agui-parser'; +import { AguiEventType } from '../../aws/agui-types'; +import { ConnectionError, type InvokeStreamingOptions, ServerError } from './invoke-types'; +import { isConnectionError, sleep } from './utils'; +import { randomUUID } from 'crypto'; + +export async function* invokeAguiStreaming(options: InvokeStreamingOptions): AsyncGenerator { + const { port, message: msg, logger, headers: customHeaders } = options; + const maxRetries = 5; + const baseDelay = 500; + let lastError: Error | null = null; + let streaming = false; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const body = { + threadId: options.threadId ?? randomUUID(), + runId: randomUUID(), + messages: [{ id: randomUUID(), role: 'user', content: msg }], + tools: [], + context: [], + state: {}, + forwardedProps: {}, + }; + + logger?.log?.('system', `AGUI invoke: ${msg}`); + + const res = await fetch(`http://localhost:${port}/invocations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + ...customHeaders, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const responseBody = await res.text(); + throw new ServerError(res.status, responseBody); + } + + if (!res.body) { + yield '(empty response)'; + return; + } + + const { eventStream } = parseAguiSSEStream({ + reader: res.body.getReader(), + logger, + singleConsumer: true, + }); + + let yieldedContent = false; + const toolCalls: { id: string; name: string; args: string }[] = []; + let activeToolId = ''; + let activeToolName = ''; + let activeToolArgs = ''; + + for await (const event of eventStream) { + switch (event.type) { + case AguiEventType.TEXT_MESSAGE_CONTENT: + case AguiEventType.TEXT_MESSAGE_CHUNK: { + const delta = (event as { delta?: string }).delta; + if (delta) { + streaming = true; + yield delta; + yieldedContent = true; + } + break; + } + case AguiEventType.TOOL_CALL_START: { + activeToolId = 'toolCallId' in event ? event.toolCallId : ''; + activeToolName = 'toolCallName' in event ? event.toolCallName : ''; + activeToolArgs = ''; + break; + } + case AguiEventType.TOOL_CALL_ARGS: { + const delta = 'delta' in event ? event.delta : undefined; + if (delta) activeToolArgs += delta; + break; + } + case AguiEventType.TOOL_CALL_END: { + if (activeToolName) { + toolCalls.push({ id: activeToolId, name: activeToolName, args: activeToolArgs }); + } + activeToolId = ''; + activeToolName = ''; + activeToolArgs = ''; + break; + } + case AguiEventType.TOOL_CALL_RESULT: { + const content = 'content' in event ? event.content : undefined; + const toolCallId = 'toolCallId' in event ? event.toolCallId : ''; + const matching = toolCalls.find(tc => tc.id === toolCallId); + if (matching && content) { + matching.args = `${matching.args} → ${typeof content === 'string' ? content : JSON.stringify(content)}`; + } + break; + } + case AguiEventType.RUN_ERROR: { + const message = 'message' in event ? event.message : 'Unknown AGUI error'; + yield `Error: ${message}`; + return; + } + default: + break; + } + } + + if (!yieldedContent && toolCalls.length > 0) { + for (const tc of toolCalls) { + yield `[Tool: ${tc.name}(${tc.args})]\n`; + } + yieldedContent = true; + } + + if (!yieldedContent) { + yield '(no content in AGUI response)'; + } + + return; + } catch (err) { + if (err instanceof ServerError) { + logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); + throw err; + } + + lastError = err instanceof Error ? err : new Error(String(err)); + + if (streaming) { + throw lastError; + } + + if (isConnectionError(lastError)) { + const delay = baseDelay * Math.pow(2, attempt); + logger?.log?.( + 'warn', + `Connection failed (attempt ${attempt + 1}/${maxRetries}): ${lastError.message}. Retrying in ${delay}ms...` + ); + await sleep(delay); + continue; + } + + logger?.log?.('error', `Request failed: ${lastError.stack ?? lastError.message}`); + throw lastError; + } + } + + const finalError = new ConnectionError(lastError ?? new Error('Failed to connect to AGUI server after retries')); + logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); + throw finalError; +} diff --git a/src/cli/operations/dev/invoke-types.ts b/src/cli/operations/dev/invoke-types.ts index 257723e18..b876b2b00 100644 --- a/src/cli/operations/dev/invoke-types.ts +++ b/src/cli/operations/dev/invoke-types.ts @@ -33,4 +33,6 @@ export interface InvokeStreamingOptions { onStatus?: (status: string) => void; /** Custom headers to forward to the agent */ headers?: Record; + /** Persistent thread ID for AGUI multi-turn conversations */ + threadId?: string; } diff --git a/src/cli/operations/dev/invoke.ts b/src/cli/operations/dev/invoke.ts index fc2ae8a12..f676c3ca8 100644 --- a/src/cli/operations/dev/invoke.ts +++ b/src/cli/operations/dev/invoke.ts @@ -1,4 +1,5 @@ import { invokeA2AStreaming } from './invoke-a2a'; +import { invokeAguiStreaming } from './invoke-agui'; import { ConnectionError, type InvokeStreamingOptions, type SSELogger, ServerError } from './invoke-types'; import { isConnectionError, sleep } from './utils'; @@ -124,7 +125,7 @@ export async function* invokeAgentStreaming( fullResponse += decoded; // Process complete lines from buffer - const lines = buffer.split('\n'); + const lines = buffer.split(/\r?\n/); buffer = lines.pop() ?? ''; for (const line of lines) { @@ -224,6 +225,9 @@ export async function* invokeForProtocol( case 'A2A': yield* invokeA2AStreaming(options); break; + case 'AGUI': + yield* invokeAguiStreaming(options); + break; default: yield* invokeAgentStreaming(options); } diff --git a/src/cli/operations/dev/load-dev-env.ts b/src/cli/operations/dev/load-dev-env.ts new file mode 100644 index 000000000..3139c4a99 --- /dev/null +++ b/src/cli/operations/dev/load-dev-env.ts @@ -0,0 +1,26 @@ +import { findConfigRoot, readEnvFile } from '../../../lib'; +import { getGatewayEnvVars } from './gateway-env.js'; +import { getMemoryEnvVars } from './memory-env.js'; + +export interface DevEnv { + /** Merged env vars: deployed-state (gateway + memory) first, then .env overrides */ + envVars: Record; + /** Number of deployed memories (based on env vars resolved from deployed state) */ + deployedMemoryCount: number; +} + +/** + * Load all dev-mode environment variables: deployed-state gateway/memory env vars + * merged with the user's .env file. Deployed-state vars go first so .env can override. + */ +export async function loadDevEnv(workingDir: string): Promise { + const configRoot = findConfigRoot(workingDir); + const dotEnvVars = configRoot ? await readEnvFile(configRoot) : {}; + const gatewayEnvVars = await getGatewayEnvVars(); + const memoryEnvVars = await getMemoryEnvVars(); + + return { + envVars: { ...gatewayEnvVars, ...memoryEnvVars, ...dotEnvVars }, + deployedMemoryCount: Object.keys(memoryEnvVars).length, + }; +} diff --git a/src/cli/operations/dev/otel/collector.ts b/src/cli/operations/dev/otel/collector.ts new file mode 100644 index 000000000..ee3ad7838 --- /dev/null +++ b/src/cli/operations/dev/otel/collector.ts @@ -0,0 +1,309 @@ +import { findAvailablePort } from '../utils'; +import { buildTraceDetail, extractFirstTraceInfo, extractTraceMeta } from './transforms'; +import type { OtlpResourceLog, OtlpResourceSpan, ProtobufType } from './types'; +import fs from 'node:fs'; +import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http'; +import path from 'node:path'; + +// Use the generated protobuf types from @opentelemetry/otlp-transformer to decode +// incoming OTLP/HTTP protobuf payloads (the default protocol for Python/Node OTEL SDKs). +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment +const otlpRoot = require('@opentelemetry/otlp-transformer/build/src/generated/root'); +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +const ExportTraceServiceRequest = otlpRoot.opentelemetry.proto.collector.trace.v1 + .ExportTraceServiceRequest as ProtobufType; +// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access +const ExportLogsServiceRequest = otlpRoot.opentelemetry.proto.collector.logs.v1 + .ExportLogsServiceRequest as ProtobufType; + +/** Standard OTLP/HTTP port */ +const DEFAULT_OTLP_PORT = 4318; + +/** Subdirectory for OTLP JSON Lines files */ +const OTLP_SUBDIR = 'otlp'; + +/** File extension for OTLP JSON Lines files */ +const OTLP_EXT = '.otlp.jsonl'; + +/** + * Lightweight in-process OTLP/HTTP receiver for dev mode. + * + * Accepts trace spans (`POST /v1/traces`) and log records (`POST /v1/logs`), + * persists them as append-only OTLP JSON Lines files, and serves them back + * with flattened attributes for frontend consumption. + * + * No in-memory store — all reads go to disk. This is fine because the frontend + * only fetches traces on user actions (page load, after invocation, manual refresh). + */ +export class OtelCollector { + private server: Server | null = null; + private port = 0; + + private readonly onLog?: (level: 'info' | 'warn' | 'error', message: string) => void; + private readonly persistDir?: string; + + constructor(options?: { + onLog?: (level: 'info' | 'warn' | 'error', message: string) => void; + persistTracesDir?: string; + }) { + this.onLog = options?.onLog; + this.persistDir = options?.persistTracesDir; + } + + /** Start the OTLP receiver. Returns the port it is listening on. */ + async start(): Promise { + this.port = await findAvailablePort(DEFAULT_OTLP_PORT); + + this.server = createServer((req: IncomingMessage, res: ServerResponse) => { + void this.handleRequest(req, res); + }); + + return new Promise((resolve, reject) => { + this.server!.listen(this.port, '127.0.0.1', () => { + this.onLog?.('info', `OTEL collector listening on port ${this.port}`); + if (this.persistDir) { + this.onLog?.('info', `OTEL trace persistence enabled → ${this.persistDir}`); + } + resolve(this.port); + }); + this.server!.on('error', reject); + }); + } + + /** Stop the OTLP receiver. */ + stop(): void { + this.server?.close(); + this.server = null; + } + + /** The port this collector is listening on (0 if not started). */ + getPort(): number { + return this.port; + } + + /** + * List recent traces, optionally filtered by time range. + * Reads from persisted JSONL files on disk. + */ + // eslint-disable-next-line @typescript-eslint/require-await + async listTraces( + agentName: string | undefined, + startTime?: number, + endTime?: number + ): Promise<{ success: boolean; traces?: unknown[]; error?: string }> { + const otlpDir = this.getOtlpDir(); + if (!otlpDir || !fs.existsSync(otlpDir)) { + return { success: true, traces: [] }; + } + + const now = Date.now(); + const start = startTime ?? now - 12 * 60 * 60 * 1000; + const end = endTime ?? now; + + const files = fs.readdirSync(otlpDir).filter(f => f.endsWith(OTLP_EXT)); + + const traces: { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount: string; + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; + }[] = []; + + for (const file of files) { + try { + const { resourceSpans, resourceLogs } = this.readTraceFile(path.join(otlpDir, file)); + + // Extract metadata from the data + const meta = extractTraceMeta(resourceSpans, resourceLogs); + if (!meta.traceId) continue; + + // Apply filters + if (meta.lastSeen < start || meta.firstSeen > end) continue; + if (agentName && meta.serviceName !== agentName) continue; + + // Flatten and filter for frontend consumption + const detail = buildTraceDetail(resourceSpans, resourceLogs); + + traces.push({ + traceId: meta.traceId, + timestamp: new Date(meta.lastSeen).toISOString(), + sessionId: meta.sessionId, + spanCount: String(meta.spanCount), + ...detail, + }); + } catch { + // Skip malformed files + } + } + + // Sort newest first + traces.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return { success: true, traces }; + } + + /** + * Get all spans and logs for a specific trace. + * Attributes are flattened and noise spans filtered for frontend consumption. + */ + // eslint-disable-next-line @typescript-eslint/require-await + async getTraceSpans( + _agentName: string | undefined, + traceId: string + ): Promise<{ success: boolean; resourceSpans?: unknown[]; resourceLogs?: unknown[]; error?: string }> { + const otlpDir = this.getOtlpDir(); + if (!otlpDir || !fs.existsSync(otlpDir)) { + return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + } + + // Find the file for this traceId + const files = fs.readdirSync(otlpDir).filter(f => f.endsWith(OTLP_EXT)); + const match = files.find(f => f.includes(traceId)); + if (!match) { + return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + } + + try { + const { resourceSpans, resourceLogs } = this.readTraceFile(path.join(otlpDir, match)); + return { success: true, ...buildTraceDetail(resourceSpans, resourceLogs) }; + } catch { + return { success: false, error: `Failed to read trace data for trace ID: ${traceId}` }; + } + } + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method === 'POST' && req.url === '/v1/traces') { + try { + const rawBody = await readBodyAsBuffer(req); + const payload = this.decodePayload(rawBody, req.headers['content-type'] ?? '', ExportTraceServiceRequest); + this.persistOtlp(payload as { resourceSpans?: OtlpResourceSpan[] }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + } catch (err) { + this.onLog?.('warn', `OTEL ingest error: ${err instanceof Error ? err.message : String(err)}`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid OTLP payload' })); + } + return; + } + + if (req.method === 'POST' && req.url === '/v1/logs') { + try { + const rawBody = await readBodyAsBuffer(req); + const payload = this.decodePayload(rawBody, req.headers['content-type'] ?? '', ExportLogsServiceRequest); + this.persistOtlp(payload as { resourceLogs?: OtlpResourceLog[] }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + } catch (err) { + this.onLog?.('warn', `OTEL log ingest error: ${err instanceof Error ? err.message : String(err)}`); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid OTLP logs payload' })); + } + return; + } + + if (req.method === 'GET' && req.url === '/') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + this.onLog?.('warn', `OTEL collector: unhandled ${req.method} ${req.url}`); + res.writeHead(404); + res.end(); + } + + /** + * Decode an OTLP protobuf or JSON payload. + * Uses JSON.stringify roundtrip on protobuf to get a plain object + * (protobufjs toJSON handles Long→string and bytes→base64). + */ + private decodePayload(raw: Buffer, contentType: string, decoder: ProtobufType): unknown { + if (contentType.includes('application/json')) { + return JSON.parse(raw.toString()); + } + return JSON.parse(JSON.stringify(decoder.decode(new Uint8Array(raw)))); + } + + /** Persist raw OTLP data as a JSON Lines entry, appended to a per-trace file. */ + private persistOtlp(data: { resourceSpans?: OtlpResourceSpan[]; resourceLogs?: OtlpResourceLog[] }): void { + const otlpDir = this.getOtlpDir(); + if (!otlpDir) return; + + try { + fs.mkdirSync(otlpDir, { recursive: true }); + + const { traceId, serviceName } = extractFirstTraceInfo(data); + if (!traceId) return; + + const sanitize = (val: string) => val.replace(/[^a-zA-Z0-9_-]/g, '_'); + const prefix = sanitize(serviceName ?? 'dev'); + const filePath = path.join(otlpDir, `${prefix}-${sanitize(traceId)}${OTLP_EXT}`); + fs.appendFileSync(filePath, JSON.stringify(data) + '\n'); + } catch (err) { + this.onLog?.('warn', `Failed to persist OTLP: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** Read and merge all JSONL entries from a trace file into combined resource arrays. */ + private readTraceFile(filePath: string): { resourceSpans: OtlpResourceSpan[]; resourceLogs: OtlpResourceLog[] } { + const content = fs.readFileSync(filePath, 'utf-8'); + const resourceSpans: OtlpResourceSpan[] = []; + const resourceLogs: OtlpResourceLog[] = []; + + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as { resourceSpans?: OtlpResourceSpan[]; resourceLogs?: OtlpResourceLog[] }; + if (entry.resourceSpans) resourceSpans.push(...entry.resourceSpans); + if (entry.resourceLogs) resourceLogs.push(...entry.resourceLogs); + } catch { + // Skip malformed lines + } + } + + return { resourceSpans, resourceLogs }; + } + + private getOtlpDir(): string | undefined { + return this.persistDir ? path.join(this.persistDir, OTLP_SUBDIR) : undefined; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readBodyAsBuffer(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +/** + * Start an OTEL collector and return it along with the env vars agents need + * to export traces to it. + */ +export async function startOtelCollector(persistTracesDir: string): Promise<{ + collector: OtelCollector; + otelEnvVars: Record; +}> { + const collector = new OtelCollector({ persistTracesDir }); + const collectorPort = await collector.start(); + + const otelEnvVars: Record = { + OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${collectorPort}`, + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf', + OTEL_METRICS_EXPORTER: 'none', + AGENT_OBSERVABILITY_ENABLED: 'true', + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'true', + OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: 'true', + }; + + return { collector, otelEnvVars }; +} diff --git a/src/cli/operations/dev/otel/index.ts b/src/cli/operations/dev/otel/index.ts new file mode 100644 index 000000000..529967bd2 --- /dev/null +++ b/src/cli/operations/dev/otel/index.ts @@ -0,0 +1,2 @@ +export { OtelCollector, startOtelCollector } from './collector'; +export type { OtlpResourceSpan, OtlpResourceLog } from './types'; diff --git a/src/cli/operations/dev/otel/transforms.ts b/src/cli/operations/dev/otel/transforms.ts new file mode 100644 index 000000000..72f42d7e1 --- /dev/null +++ b/src/cli/operations/dev/otel/transforms.ts @@ -0,0 +1,286 @@ +import type { OtlpAttribute, OtlpAttributeValue, OtlpResource, OtlpResourceLog, OtlpResourceSpan } from './types'; + +// --------------------------------------------------------------------------- +// Trace metadata extraction (from raw OTLP data) +// --------------------------------------------------------------------------- + +export interface TraceMeta { + traceId?: string; + firstSeen: number; + lastSeen: number; + sessionId?: string; + serviceName?: string; + spanCount: number; +} + +/** Extract metadata from raw OTLP resource arrays. */ +export function extractTraceMeta(resourceSpans: OtlpResourceSpan[], resourceLogs: OtlpResourceLog[]): TraceMeta { + let traceId: string | undefined; + let firstSeen = Infinity; + let lastSeen = 0; + let sessionId: string | undefined; + let serviceName: string | undefined; + let spanCount = 0; + + for (const rs of resourceSpans) { + serviceName ??= getResourceAttribute(rs.resource, 'service.name'); + for (const ss of rs.scopeSpans ?? []) { + for (const span of ss.spans ?? []) { + spanCount++; + traceId ??= hexFromB64OrString(span.traceId) || undefined; + const startMs = nanoToMs(span.startTimeUnixNano); + const endMs = nanoToMs(span.endTimeUnixNano); + if (startMs && startMs < firstSeen) firstSeen = startMs; + if (endMs && endMs > lastSeen) lastSeen = endMs; + if (!sessionId) { + const attrs = span.attributes; + sessionId = getAttrValue(attrs, 'session.id') ?? getAttrValue(attrs, 'attributes.session.id'); + } + } + } + } + + for (const rl of resourceLogs) { + serviceName ??= getResourceAttribute(rl.resource, 'service.name'); + for (const sl of rl.scopeLogs ?? []) { + for (const lr of sl.logRecords ?? []) { + spanCount++; + traceId ??= hexFromB64OrString(lr.traceId) || undefined; + const timeMs = nanoToMs(lr.timeUnixNano) || nanoToMs(lr.observedTimeUnixNano); + if (timeMs && timeMs < firstSeen) firstSeen = timeMs; + if (timeMs && timeMs > lastSeen) lastSeen = timeMs; + } + } + } + + const now = Date.now(); + if (firstSeen === Infinity) firstSeen = now; + if (lastSeen === 0) lastSeen = now; + + return { traceId, firstSeen, lastSeen, sessionId, serviceName, spanCount }; +} + +/** Extract traceId and serviceName from the first span/log in a payload. */ +export function extractFirstTraceInfo(data: { resourceSpans?: OtlpResourceSpan[]; resourceLogs?: OtlpResourceLog[] }): { + traceId?: string; + serviceName?: string; +} { + if (data.resourceSpans) { + for (const rs of data.resourceSpans) { + const svc = getResourceAttribute(rs.resource, 'service.name'); + for (const ss of rs.scopeSpans ?? []) { + for (const span of ss.spans ?? []) { + if (span.traceId) return { traceId: hexFromB64OrString(span.traceId), serviceName: svc }; + } + } + } + } + if (data.resourceLogs) { + for (const rl of data.resourceLogs) { + const svc = getResourceAttribute(rl.resource, 'service.name'); + for (const sl of rl.scopeLogs ?? []) { + for (const lr of sl.logRecords ?? []) { + if (lr.traceId) return { traceId: hexFromB64OrString(lr.traceId), serviceName: svc }; + } + } + } + } + return {}; +} + +// --------------------------------------------------------------------------- +// Trace detail: flatten attributes, filter noise, extract log bodies +// --------------------------------------------------------------------------- + +/** + * Build frontend-ready trace detail from raw OTLP resource arrays. + * Flattens attributes to Record, filters noise spans, + * and extracts log body values. + */ +export function buildTraceDetail( + resourceSpans: OtlpResourceSpan[], + resourceLogs: OtlpResourceLog[] +): { resourceSpans?: unknown[]; resourceLogs?: unknown[] } { + const filteredSpans = resourceSpans + .map(rs => ({ + resource: rs.resource ? { attributes: flattenAttributes(rs.resource.attributes) } : undefined, + scopeSpans: rs.scopeSpans + ?.map(ss => ({ + scope: ss.scope, + spans: ss.spans + ?.map(span => ({ + ...span, + traceId: hexFromB64OrString(span.traceId), + spanId: hexFromB64OrString(span.spanId), + parentSpanId: hexFromB64OrString(span.parentSpanId), + attributes: flattenAttributes(span.attributes), + })) + .filter(span => isMeaningfulSpan(span)), + })) + .filter(ss => ss.spans && ss.spans.length > 0), + })) + .filter(rs => rs.scopeSpans && rs.scopeSpans.length > 0); + + const flattenedLogs = resourceLogs + .map(rl => ({ + resource: rl.resource ? { attributes: flattenAttributes(rl.resource.attributes) } : undefined, + scopeLogs: rl.scopeLogs?.map(sl => ({ + scope: sl.scope, + logRecords: sl.logRecords?.map(lr => ({ + ...lr, + traceId: hexFromB64OrString(lr.traceId), + spanId: hexFromB64OrString(lr.spanId), + body: lr.body ? extractAnyValue(lr.body) : undefined, + attributes: flattenAttributes(lr.attributes), + })), + })), + })) + .filter(rl => rl.scopeLogs && rl.scopeLogs.length > 0); + + return { + resourceSpans: filteredSpans.length > 0 ? filteredSpans : undefined, + resourceLogs: flattenedLogs.length > 0 ? flattenedLogs : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Span filtering +// --------------------------------------------------------------------------- + +/** + * Determine if a trace span contains meaningful application data. + * Filters out ASGI transport noise, HTTP client noise, and other + * low-level framework spans that add no value in the trace UI. + */ +function isMeaningfulSpan(span: { + name?: string; + kind?: number | string; + attributes?: Record; +}): boolean { + const name = span.name ?? ''; + const attrs = span.attributes ?? {}; + const kind = normalizeSpanKind(span.kind); + + if (name.endsWith(' http send') || name.endsWith(' http receive')) return false; + if (attrs['asgi.event.type']) return false; + if (Object.keys(attrs).some(k => k.startsWith('gen_ai.'))) return true; + if (attrs['rpc.system'] || attrs['rpc.method']) return true; + + const scopeHints = ['strands', 'bedrock', 'langchain', 'crewai', 'autogen', 'google_adk']; + if (scopeHints.some(h => name.toLowerCase().includes(h))) return true; + if (name === 'tool_use' || name === 'tool_call' || attrs['tool.name']) return true; + + if (kind === 3 && (name === 'POST' || name === 'GET' || name.startsWith('HTTP '))) return false; + if (kind === 2 && name.startsWith('POST /') && attrs['http.method']) return false; + + return true; +} + +/** Normalize span kind from string enum name or number to a numeric value. */ +function normalizeSpanKind(kind: number | string | undefined): number { + if (typeof kind === 'number') return kind; + if (typeof kind === 'string') { + const map: Record = { + SPAN_KIND_INTERNAL: 1, + SPAN_KIND_SERVER: 2, + SPAN_KIND_CLIENT: 3, + SPAN_KIND_PRODUCER: 4, + SPAN_KIND_CONSUMER: 5, + }; + return map[kind] ?? 0; + } + return 0; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Convert nanosecond timestamp (string) to milliseconds. */ +export function nanoToMs(nano: string | undefined): number { + if (!nano) return 0; + return Math.floor(Number(nano) / 1_000_000); +} + +/** + * Convert a value that may be base64 (from protobuf JSON roundtrip) or + * already a hex string into a hex string. + */ +export function hexFromB64OrString(val: string | undefined): string { + if (!val) return ''; + // Already hex (32 chars for traceId, 16 for spanId) + if (/^[0-9a-f]+$/i.test(val) && (val.length === 32 || val.length === 16)) return val.toLowerCase(); + // Base64 from protobuf JSON.stringify roundtrip + try { + return Buffer.from(val, 'base64').toString('hex'); + } catch { + return val; + } +} + +/** Get a string attribute from an OTLP resource. */ +function getResourceAttribute(resource: OtlpResource | undefined, key: string): string | undefined { + return getAttrValue(resource?.attributes, key); +} + +/** Get a string value from attributes (handles both array and flat record formats). */ +function getAttrValue(attrs: OtlpAttribute[] | Record | undefined, key: string): string | undefined { + if (!attrs) return undefined; + if (Array.isArray(attrs)) { + const attr = attrs.find(a => a.key === key); + if (!attr?.value) return undefined; + return attr.value.stringValue ?? (attr.value.intValue != null ? String(attr.value.intValue) : undefined); + } + const val = attrs[key]; + return typeof val === 'string' ? val : undefined; +} + +/** + * Flatten OTLP attributes to a plain Record. + * Handles both OTLP key/value array format and already-flat records. + */ +export function flattenAttributes( + attrs: OtlpAttribute[] | Record | undefined +): Record | undefined { + if (!attrs) return undefined; + if (!Array.isArray(attrs)) return attrs; + if (attrs.length === 0) return undefined; + + const result: Record = {}; + for (const attr of attrs) { + if (!attr.value) continue; + if (attr.value.stringValue !== undefined) result[attr.key] = attr.value.stringValue; + else if (attr.value.intValue !== undefined) result[attr.key] = Number(attr.value.intValue); + else if (attr.value.doubleValue !== undefined) result[attr.key] = attr.value.doubleValue; + else if (attr.value.boolValue !== undefined) result[attr.key] = attr.value.boolValue; + else if (attr.value.arrayValue?.values) { + result[attr.key] = attr.value.arrayValue.values.map( + (v: OtlpAttributeValue) => v.stringValue ?? v.intValue ?? v.doubleValue ?? v.boolValue ?? null + ); + } + } + return result; +} + +/** Extract a usable value from an OTLP AnyValue. */ +export function extractAnyValue(val: unknown): unknown { + if (!val || typeof val !== 'object') return val; + const v = val as Record; + if (v.stringValue !== undefined) return v.stringValue; + if (v.intValue !== undefined) return Number(v.intValue); + if (v.doubleValue !== undefined) return v.doubleValue; + if (v.boolValue !== undefined) return v.boolValue; + if (v.arrayValue && typeof v.arrayValue === 'object') { + const arr = v.arrayValue as { values?: unknown[] }; + return (arr.values ?? []).map(extractAnyValue); + } + if (v.kvlistValue && typeof v.kvlistValue === 'object') { + const kvlist = v.kvlistValue as { values?: { key: string; value?: unknown }[] }; + const obj: Record = {}; + for (const kv of kvlist.values ?? []) { + obj[kv.key] = kv.value ? extractAnyValue(kv.value) : undefined; + } + return obj; + } + return val; +} diff --git a/src/cli/operations/dev/otel/types.ts b/src/cli/operations/dev/otel/types.ts new file mode 100644 index 000000000..cf8131468 --- /dev/null +++ b/src/cli/operations/dev/otel/types.ts @@ -0,0 +1,56 @@ +/** Protobuf decoder interface for OTLP messages. */ +export interface ProtobufType { + decode(data: Uint8Array): unknown; +} + +export interface OtlpResource { + attributes?: OtlpAttribute[] | Record; +} + +export interface OtlpAttribute { + key: string; + value?: OtlpAttributeValue; +} + +export interface OtlpAttributeValue { + stringValue?: string; + intValue?: string; + doubleValue?: number; + boolValue?: boolean; + arrayValue?: { values?: OtlpAttributeValue[] }; + kvlistValue?: { values?: OtlpAttribute[] }; +} + +export interface OtlpResourceSpan { + resource?: OtlpResource; + scopeSpans?: { scope?: { name?: string; version?: string }; spans?: OtlpSpan[] }[]; +} + +export interface OtlpSpan { + traceId?: string; + spanId?: string; + parentSpanId?: string; + name?: string; + kind?: number; + startTimeUnixNano?: string; + endTimeUnixNano?: string; + attributes?: OtlpAttribute[] | Record; + status?: { code?: number; message?: string }; + events?: unknown[]; +} + +export interface OtlpResourceLog { + resource?: OtlpResource; + scopeLogs?: { scope?: { name?: string; version?: string }; logRecords?: OtlpLogRecord[] }[]; +} + +export interface OtlpLogRecord { + timeUnixNano?: string; + observedTimeUnixNano?: string; + severityNumber?: number; + severityText?: string; + body?: unknown; + attributes?: OtlpAttribute[] | Record; + traceId?: string; + spanId?: string; +} diff --git a/src/cli/operations/dev/utils.ts b/src/cli/operations/dev/utils.ts index 112b31b23..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 */ @@ -71,6 +72,8 @@ export function getEndpointUrl(port: number, protocol: string): string { return `http://localhost:${port}/mcp`; case 'A2A': return `http://localhost:${port}/`; + case 'AGUI': + return `http://localhost:${port}/invocations`; default: return `http://localhost:${port}/invocations`; } @@ -106,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/README.md b/src/cli/operations/dev/web-ui/README.md new file mode 100644 index 000000000..c33f0ea0c --- /dev/null +++ b/src/cli/operations/dev/web-ui/README.md @@ -0,0 +1,236 @@ +# Web UI (Browser Mode) + +Browser mode (`agentcore dev`) launches a local proxy server that serves both the chat UI and API endpoints. + +## Architecture + +``` +Browser → http://127.0.0.1:8081 + | + Node.js Server (port: findAvailablePort(8081)) + ├─ Serves frontend (static files from built agent-inspector) + └─ API endpoints (/api/status, /invocations, etc.) + | + | HTTP (deterministic port: proxyPort + 1 + agentIndex) + v + Python Agent Server (uvicorn or Docker) +``` + +Two processes are always involved: + +- The **server** is a Node.js HTTP server. It serves the frontend, handles agent selection API calls, and forwards + invocations to the correct Python agent. +- The **agent server** is a Python process (uvicorn for CodeZip, Docker for Container). It is started on demand when the + frontend selects an agent. + +## Port Assignment + +| Process | Port | +| ------- | ----------------------------------------------------------- | +| Server | `findAvailablePort(8081)` — tries 8081, increments if taken | +| Agent 0 | `proxyPort + 1` | +| Agent 1 | `proxyPort + 2` | +| Agent N | `proxyPort + 1 + N` | + +Ports are deterministic relative to the proxy port, so no scanning is needed for agents. + +## Frontend + +The chat UI lives in the `@aws/agent-inspector` package. At build time, it produces static files (index.html, index.js, +index.css) that are copied to `dist/agent-inspector/`. The Node.js server serves these files for any non-API GET +request, with SPA fallback to `index.html`. + +### Frontend Development (Hot Reload) + +For frontend development with hot module replacement: + +1. Terminal 1: `agentcore dev` (starts the API server) +2. Terminal 2: `npm run dev:ui` (starts Vite dev server on localhost:5173) +3. Open `http://localhost:5173?port=8081` in your browser + +The `?port=` query param tells the frontend to connect to the CLI's API server. The CLI allows `localhost:5173` in its +CORS allowlist for this workflow. + +## API Endpoints + +All endpoints are served by the Node.js server. Types are defined in `api-types.ts` and exported from the package so the +frontend can import them: + +```ts +import type { ResourceDeploymentStatus, ResourcesResponse, StatusAgentError, StatusResponse } from '@aws/agentcore'; +``` + +### `GET /api/status` + +Returns available agents, which ones are currently running, and any per-agent errors (e.g. failed to start, server +crashed). + +```json +{ + "agents": [{ "name": "MyAgent", "buildType": "CodeZip" }], + "running": [{ "name": "MyAgent", "port": 8082 }], + "errors": [] +} +``` + +When an agent fails to start (e.g. Docker not ready, missing Dockerfile, server crash), the `errors` array includes the +agent name and error message: + +```json +{ + "agents": [{ "name": "MyAgent", "buildType": "Container" }], + "running": [], + "errors": [ + { + "name": "MyAgent", + "message": "Found docker, podman, finch but not ready. Start a runtime:\ndocker: Start Docker Desktop or run: sudo systemctl start docker" + } + ] +} +``` + +Errors are cleared when the agent is successfully started again via `POST /api/start`. + +The agent list is kept in sync with `agentcore.json` via `fs.watch` — if you add or remove an agent in another terminal, +the status endpoint reflects the change without restarting the dev server. + +### `GET /api/resources` + +Returns the full project resource graph by reading config files (`agentcore.json`, `mcp.json`, `deployed-state.json`) on +each call (always fresh). + +Each resource includes an optional `deploymentStatus` field computed by diffing local config against the deployed state +file (same logic as `agentcore status`). Possible values: + +- `"deployed"` — exists both locally and in AWS +- `"local-only"` — exists in config but hasn't been deployed yet +- `"pending-removal"` — removed from local config but still exists in AWS + +The field is `undefined` when no deployed state file exists (project has never been deployed). + +```json +{ + "success": true, + "project": "MyProject", + "agents": [ + { + "name": "MyAgent", + "build": "CodeZip", + "entrypoint": "main.py:handler", + "codeLocation": "app/MyAgent", + "runtimeVersion": "PYTHON_3_13", + "networkMode": "PUBLIC", + "envVars": ["OPENAI_API_KEY"], + "deploymentStatus": "deployed" + } + ], + "memories": [ + { + "name": "MyMemory", + "strategies": [{ "type": "SEMANTIC", "namespaces": [] }], + "expiryDays": 30, + "deploymentStatus": "local-only" + } + ], + "credentials": [{ "name": "anthropic-key", "type": "ApiKeyCredentialProvider", "deploymentStatus": "deployed" }], + "gateways": [ + { + "name": "my-gateway", + "targets": [{ "name": "my-tool", "targetType": "lambda" }], + "deploymentStatus": "local-only" + } + ], + "mcpRuntimeTools": [{ "name": "my-mcp-tool", "bindings": [{ "agentName": "MyAgent", "envVarName": "MCP_TOOL_ARN" }] }] +} +``` + +### `POST /api/start` + +Starts an agent server on demand. If already running, returns the existing port. + +Request: + +```json +{ "agentName": "MyAgent" } +``` + +Response: + +```json +{ "success": true, "name": "MyAgent", "port": 8082 } +``` + +Error: + +```json +{ "success": false, "error": "Agent \"MyAgent\" not found or not supported" } +``` + +### `POST /invocations` + +Proxies a chat invocation to the selected running agent. The `agentName` field routes to the correct agent; falls back +to the first running agent if omitted. + +Request: + +```json +{ "agentName": "MyAgent", "prompt": "Hello", "sessionId": "abc", "userId": "user1" } +``` + +### `GET /api/traces?agentName=xxx[&startTime=ms&endTime=ms]` + +Lists recent traces for an agent. Available when the OTEL collector is active. + +Query parameters: + +- `agentName` (required) — agent to query traces for +- `startTime` (optional) — start of the time range in epoch milliseconds. Defaults to 12 hours before `endTime`. +- `endTime` (optional) — end of the time range in epoch milliseconds. Defaults to now. + +Response: + +```json +{ "success": true, "traces": [...] } +``` + +### `GET /api/traces/:traceId?agentName=xxx[&startTime=ms&endTime=ms]` + +Returns full trace data (spans) for a specific trace. Available when the OTEL collector is active. + +Query parameters: + +- `agentName` (required) — agent the trace belongs to +- `startTime` (optional) — start of the time range in epoch milliseconds. Defaults to 12 hours before `endTime`. +- `endTime` (optional) — end of the time range in epoch milliseconds. Defaults to now. + +Response: + +```json +{ "success": true, "spans": [...] } +``` + +### `GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz]` + +Lists memory records for a given memory and namespace. Requires a deployed memory with `onListMemoryRecords` handler. + +Response: + +```json +{ "success": true, "records": [...], "nextToken": "..." } +``` + +### `POST /api/memory/search` + +Performs semantic search across memory records. Requires a deployed memory with `onRetrieveMemoryRecords` handler. + +Request: + +```json +{ "memoryName": "MyMemory", "namespace": "/users/123/facts", "searchQuery": "preferences", "strategyId": "optional" } +``` + +Response: + +```json +{ "success": true, "records": [...] } +``` diff --git a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts new file mode 100644 index 000000000..c2dcfc615 --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts @@ -0,0 +1,175 @@ +import { handleMcpProxy } from '../handlers/mcp-proxy.js'; +import type { RouteContext } from '../handlers/route-context.js'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +function mockReq(_body: string): IncomingMessage { + return {} as IncomingMessage; +} + +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 mockCtx(overrides: Partial = {}): RouteContext { + return { + options: { mode: 'dev' } as RouteContext['options'], + runningAgents: new Map(), + startingAgents: new Map(), + agentErrors: new Map(), + setCorsHeaders: vi.fn(), + readBody: vi.fn(), + ...overrides, + } as unknown as RouteContext; +} + +describe('handleMcpProxy', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 400 when agentName is missing', async () => { + const ctx = mockCtx({ readBody: vi.fn().mockResolvedValue(JSON.stringify({ body: {} })) }); + const req = mockReq(''); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'agentName is required' }); + }); + + it('returns 400 when body is missing', async () => { + const ctx = mockCtx({ readBody: vi.fn().mockResolvedValue(JSON.stringify({ agentName: 'test-agent' })) }); + const req = mockReq(''); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'body is required' }); + }); + + it('returns 400 when agent is not running', async () => { + const ctx = mockCtx({ + readBody: vi.fn().mockResolvedValue(JSON.stringify({ agentName: 'test-agent', body: { jsonrpc: '2.0' } })), + }); + const req = mockReq(''); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'Agent "test-agent" is not running' }); + }); + + it('forwards JSON-RPC to agent and returns result', async () => { + const agents = new Map([['test-agent', { server: {} as any, port: 8082, protocol: 'MCP' }]]); + const jsonRpcBody = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }; + const ctx = mockCtx({ + runningAgents: agents, + readBody: vi.fn().mockResolvedValue(JSON.stringify({ agentName: 'test-agent', body: jsonRpcBody })), + }); + const req = mockReq(''); + const res = mockRes(); + + const mcpResponse = { jsonrpc: '2.0', id: 1, result: { tools: [] } }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'mcp-session-id': 'session-123' }), + text: () => Promise.resolve(JSON.stringify(mcpResponse)), + }) + ); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(200); + const parsed = JSON.parse(res._body); + expect(parsed).toEqual({ success: true, result: mcpResponse, sessionId: 'session-123' }); + + const fetchCall = (globalThis.fetch as ReturnType).mock.calls[0]!; + expect(fetchCall[0]).toBe('http://localhost:8082/mcp'); + expect(JSON.parse(fetchCall[1].body)).toEqual(jsonRpcBody); + + vi.unstubAllGlobals(); + }); + + it('passes mcp-session-id header from request to agent', async () => { + const agents = new Map([['test-agent', { server: {} as any, port: 8082, protocol: 'MCP' }]]); + const jsonRpcBody = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} }; + const ctx = mockCtx({ + runningAgents: agents, + readBody: vi + .fn() + .mockResolvedValue( + JSON.stringify({ agentName: 'test-agent', body: jsonRpcBody, sessionId: 'existing-session' }) + ), + }); + const req = mockReq(''); + const res = mockRes(); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({}), + text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} })), + }) + ); + + await handleMcpProxy(ctx, req, res, undefined); + + const fetchCall = (globalThis.fetch as ReturnType).mock.calls[0]!; + expect(fetchCall[1].headers['mcp-session-id']).toBe('existing-session'); + + vi.unstubAllGlobals(); + }); + + it('returns 502 when agent returns non-ok response', async () => { + const agents = new Map([['test-agent', { server: {} as any, port: 8082, protocol: 'MCP' }]]); + const ctx = mockCtx({ + runningAgents: agents, + readBody: vi + .fn() + .mockResolvedValue( + JSON.stringify({ agentName: 'test-agent', body: { jsonrpc: '2.0', id: 1, method: 'tools/list' } }) + ), + }); + const req = mockReq(''); + const res = mockRes(); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error'), + }) + ); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(502); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'MCP server returned status 500' }); + + vi.unstubAllGlobals(); + }); +}); 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/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts new file mode 100644 index 000000000..509d834ff --- /dev/null +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -0,0 +1,380 @@ +/** + * Shared API contract types for the Web UI proxy server. + * + * These types define the request/response shapes for all HTTP endpoints + * served by WebUIServer. The frontend repo maintains its own copy of + * these types — keep both in sync when changing endpoint shapes. + * + * TODO: Extract these types into a shared package so both repos import + * from a single source of truth instead of manually duplicating. + */ + +// --------------------------------------------------------------------------- +// GET /api/status +// --------------------------------------------------------------------------- + +/** Response shape for GET /api/status */ +export interface StatusResponse { + agents: StatusAgent[]; + running: StatusRunningAgent[]; + errors: StatusAgentError[]; + /** Agent name to pre-select in the UI (set when --runtime is specified) */ + selectedAgent?: string; +} + +/** Agent metadata returned in the status response */ +export interface StatusAgent { + name: string; + buildType: string; + protocol: string; +} + +/** Running agent entry in the status response */ +export interface StatusRunningAgent { + name: string; + /** Port the agent is listening on. */ + port: number; +} + +/** Per-agent error state in the status response */ +export interface StatusAgentError { + name: string; + message: string; +} + +// --------------------------------------------------------------------------- +// GET /api/resources +// --------------------------------------------------------------------------- + +/** Deployment state for a resource: matches the status command's ResourceDeploymentState */ +export type ResourceDeploymentStatus = 'deployed' | 'local-only' | 'pending-removal'; + +/** Deployed state for an agent runtime */ +export interface DeployedAgentState { + runtimeId: string; + runtimeArn: string; + roleArn: string; +} + +/** Deployed state for a memory */ +export interface DeployedMemoryState { + memoryId: string; + memoryArn: string; +} + +/** Deployed state for a credential */ +export interface DeployedCredentialState { + credentialProviderArn: string; + clientSecretArn?: string; + callbackUrl?: string; +} + +/** Deployed state for a gateway */ +export interface DeployedGatewayState { + gatewayId: string; + gatewayArn: string; + gatewayUrl?: string; +} + +/** Deployed state for an evaluator */ +export interface DeployedEvaluatorState { + evaluatorId: string; + evaluatorArn: string; +} + +/** Deployed state for an online eval config */ +export interface DeployedOnlineEvalState { + onlineEvaluationConfigId: string; + onlineEvaluationConfigArn: string; + executionStatus?: 'ENABLED' | 'DISABLED'; +} + +/** Deployed state for a policy engine */ +export interface DeployedPolicyEngineState { + policyEngineId: string; + policyEngineArn: string; +} + +/** Deployed state for a policy */ +export interface DeployedPolicyState { + policyId: string; + policyArn: string; + engineName: string; +} + +/** Successful response shape for GET /api/resources */ +export interface ResourcesResponse { + success: true; + project: string; + agents: ResourceAgent[]; + memories: ResourceMemory[]; + credentials: ResourceCredential[]; + gateways: ResourceGateway[]; + mcpRuntimeTools: ResourceMcpTool[]; + evaluators: ResourceEvaluator[]; + onlineEvalConfigs: ResourceOnlineEvalConfig[]; + policyEngines: ResourcePolicyEngine[]; + unassignedTargets: ResourceUnassignedTarget[]; +} + +/** Agent details in the resources response */ +export interface ResourceAgent { + name: string; + build: string; + entrypoint: string; + codeLocation: string; + runtimeVersion: string; + networkMode: string; + protocol: string; + envVars: string[]; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedAgentState; + invocationUrl?: string; +} + +/** Memory details in the resources response */ +export interface ResourceMemory { + name: string; + strategies: ResourceMemoryStrategy[]; + expiryDays: number | undefined; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedMemoryState; +} + +/** Memory strategy with namespace patterns */ +export interface ResourceMemoryStrategy { + type: string; + /** Namespace patterns, e.g. "/users/{actorId}/facts", "/summaries/{actorId}/{sessionId}" */ + namespaces: string[]; +} + +/** Credential details in the resources response */ +export interface ResourceCredential { + name: string; + type: string; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedCredentialState; +} + +/** Gateway details in the resources response */ +export interface ResourceGateway { + name: string; + targets: ResourceGatewayTarget[]; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedGatewayState; +} + +/** Gateway target details */ +export interface ResourceGatewayTarget { + name: string; + targetType: string; +} + +/** MCP runtime tool details in the resources response */ +export interface ResourceMcpTool { + name: string; + bindings: ResourceMcpToolBinding[]; + deploymentStatus?: ResourceDeploymentStatus; +} + +/** MCP tool binding to a runtime */ +export interface ResourceMcpToolBinding { + runtimeName: string; + envVarName: string; +} + +/** Evaluator details in the resources response */ +export interface ResourceEvaluator { + name: string; + level: string; + description?: string; + configType: 'llm-as-a-judge' | 'code-based'; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedEvaluatorState; +} + +/** Online eval config details in the resources response */ +export interface ResourceOnlineEvalConfig { + name: string; + agent: string; + evaluators: string[]; + samplingRate: number; + description?: string; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedOnlineEvalState; +} + +/** Policy engine details in the resources response */ +export interface ResourcePolicyEngine { + name: string; + description?: string; + policies: ResourcePolicy[]; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedPolicyEngineState; +} + +/** Policy details in the resources response */ +export interface ResourcePolicy { + name: string; + description?: string; + deploymentStatus?: ResourceDeploymentStatus; + deployed?: DeployedPolicyState; +} + +/** Unassigned gateway target details in the resources response */ +export interface ResourceUnassignedTarget { + name: string; + targetType: string; +} + +// --------------------------------------------------------------------------- +// POST /api/start +// --------------------------------------------------------------------------- + +/** Request body for POST /api/start */ +export interface StartRequest { + agentName: string; +} + +/** Response shape for POST /api/start */ +export interface StartResponse { + success: boolean; + name: string; + port: number; + error?: string; +} + +// --------------------------------------------------------------------------- +// POST /invocations +// --------------------------------------------------------------------------- + +/** Request body for POST /invocations */ +export interface InvocationRequest { + agentName?: string; + prompt?: string; + sessionId?: string; + userId?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/traces?agentName=xxx +// --------------------------------------------------------------------------- + +/** Response shape for GET /api/traces */ +export interface ListTracesResponse { + success: boolean; + traces?: unknown[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/traces/:traceId?agentName=xxx +// --------------------------------------------------------------------------- + +/** Response shape for GET /api/traces/:traceId */ +export interface GetTraceResponse { + success: boolean; + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz] +// --------------------------------------------------------------------------- + +/** Response shape for GET /api/memory */ +export interface ListMemoryRecordsResponse { + success: boolean; + records?: MemoryRecordResponse[]; + nextToken?: string; + error?: string; +} + +/** A single memory record in list/search responses */ +export interface MemoryRecordResponse { + memoryRecordId: string; + content: string | undefined; + memoryStrategyId: string; + namespaces: string[]; + createdAt: string; + score: number | undefined; + metadata: Record; +} + +// --------------------------------------------------------------------------- +// POST /api/memory/search +// --------------------------------------------------------------------------- + +/** Request body for POST /api/memory/search */ +export interface RetrieveMemoryRecordsRequest { + memoryName: string; + namespace: string; + searchQuery: string; + strategyId?: string; +} + +/** Response shape for POST /api/memory/search */ +export interface RetrieveMemoryRecordsResponse { + success: boolean; + records?: MemoryRecordResponse[]; + nextToken?: string; + error?: string; +} + +// --------------------------------------------------------------------------- +// Common error response (used by all endpoints on failure) +// --------------------------------------------------------------------------- + +/** Error response shape returned by any endpoint on failure */ +export interface ApiErrorResponse { + success: false; + error: string; +} + +// --------------------------------------------------------------------------- +// POST /api/mcp — Thin proxy that forwards JSON-RPC to an agent's MCP endpoint +// --------------------------------------------------------------------------- + +/** Request body for POST /api/mcp */ +export interface McpProxyRequest { + agentName: string; + body: Record; +} + +/** Response shape for POST /api/mcp */ +export interface McpProxyResponse { + success: true; + result: unknown; + sessionId?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/a2a/agent-card?agentName=xxx — Fetch A2A agent card +// --------------------------------------------------------------------------- + +/** A2A agent skill metadata */ +export interface A2AAgentSkill { + id?: string; + name?: string; + description?: string; + tags?: string[]; +} + +/** A2A agent card returned by /.well-known/agent.json */ +export interface A2AAgentCard { + name?: string; + description?: string; + version?: string; + url?: string; + skills?: A2AAgentSkill[]; + capabilities?: { streaming?: boolean }; + defaultInputModes?: string[]; + defaultOutputModes?: string[]; +} + +/** Response shape for GET /api/a2a/agent-card */ +export interface A2AAgentCardResponse { + success: true; + card: A2AAgentCard; +} diff --git a/src/cli/operations/dev/web-ui/constants.ts b/src/cli/operations/dev/web-ui/constants.ts new file mode 100644 index 000000000..1ff6d9361 --- /dev/null +++ b/src/cli/operations/dev/web-ui/constants.ts @@ -0,0 +1,18 @@ +import type { StatusAgent, StatusRunningAgent } from './api-types'; + +export const WEB_UI_LOCAL_URL = 'http://localhost:5173'; + +/** Default port for the web UI proxy server. Agent ports start above this. */ +export const WEB_UI_DEFAULT_PORT = 8081; + +/** Metadata about an available agent, passed to WebUIServer at startup */ +export type AgentInfo = StatusAgent; + +/** Runtime state of a started agent server */ +export type RunningAgent = StatusRunningAgent; + +/** Per-agent error state tracked by the web UI server */ +export interface AgentError { + message: string; + timestamp: number; +} diff --git a/src/cli/operations/dev/web-ui/handlers/a2a-proxy.ts b/src/cli/operations/dev/web-ui/handlers/a2a-proxy.ts new file mode 100644 index 000000000..11d41852c --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/a2a-proxy.ts @@ -0,0 +1,48 @@ +import { type RouteContext, parseRequestUrl } from './route-context'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** GET /api/a2a/agent-card?agentName=xxx — fetch A2A agent card from the running agent */ +export async function handleA2AAgentCard( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + ctx.setCorsHeaders(res, origin); + + const { param } = parseRequestUrl(req); + const agentName = param('agentName'); + + if (!agentName) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'agentName query parameter is required' })); + return; + } + + const running = ctx.runningAgents.get(agentName); + if (!running) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Agent "${agentName}" is not running` })); + return; + } + + try { + const cardRes = await fetch(`http://localhost:${running.port}/.well-known/agent.json`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!cardRes.ok) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Agent card not available (${cardRes.status})` })); + return; + } + + const card = await cardRes.json(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, card })); + } catch (err) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Failed to fetch agent card: ${(err as Error).message}` })); + } +} diff --git a/src/cli/operations/dev/web-ui/handlers/index.ts b/src/cli/operations/dev/web-ui/handlers/index.ts new file mode 100644 index 000000000..91d2d4d5d --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/index.ts @@ -0,0 +1,9 @@ +export { type RouteContext, parseRequestUrl } from './route-context'; +export { handleStatus } from './status'; +export { handleResources } from './resources'; +export { handleStart } from './start'; +export { handleInvocations } from './invocations'; +export { handleListTraces, handleGetTrace } from './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 new file mode 100644 index 000000000..4123a696d --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -0,0 +1,332 @@ +import { extractSSEEventText, extractTaskText, isStatusUpdateEvent } from '../../invoke-a2a'; +import type { RouteContext } from './route-context'; +import { randomUUID } from 'node:crypto'; +import { type IncomingMessage, type ServerResponse, request as httpRequest } from 'node:http'; + +let a2aRequestId = 1; + +/** + * POST /invocations — proxy to the selected agent. + * Body must include agentName to route to the correct running agent. + */ +export async function handleInvocations( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const body = await ctx.readBody(req); + + let agentPort: number | undefined; + let agentName: string | undefined; + let agentProtocol: string | undefined; + let sessionId: string | undefined; + let userId: string | undefined; + try { + const parsed = JSON.parse(body) as { agentName?: string; sessionId?: string; userId?: string }; + agentName = parsed.agentName; + sessionId = parsed.sessionId ?? randomUUID(); + userId = parsed.userId; + if (agentName) { + const running = ctx.runningAgents.get(agentName); + agentPort = running?.port; + agentProtocol = running?.protocol; + } + } catch { + // fall through + } + + // Clear any previous runtime error for this agent so stale errors don't persist + if (agentName) { + ctx.agentErrors.delete(agentName); + } + + // Fall back to first running agent + if (agentPort === undefined) { + const first = ctx.runningAgents.values().next().value; + agentPort = first?.port; + agentProtocol = first?.protocol; + } + + if (agentPort === undefined) { + ctx.setCorsHeaders(res, origin); + res.writeHead(409, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'No agent is running. Call POST /api/start first.' })); + return; + } + + // A2A agents use JSON-RPC at root path, not /invocations + if (agentProtocol === 'A2A') { + return handleA2AInvocation(ctx, res, body, agentPort, sessionId, origin); + } + + // AGUI agents need RunAgentInput body; SSE response is passed through raw + if (agentProtocol === 'AGUI') { + return handleAguiInvocation(ctx, res, body, agentPort, sessionId, userId, origin); + } + + return new Promise((resolve, reject) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'x-amzn-bedrock-agentcore-runtime-session-id': sessionId ?? randomUUID(), + }; + if (userId) { + headers['x-amzn-bedrock-agentcore-runtime-user-id'] = userId; + } + + const proxyReq = httpRequest( + { + hostname: '127.0.0.1', + port: agentPort, + path: '/invocations', + method: 'POST', + headers, + }, + agentRes => { + const contentType = agentRes.headers['content-type'] ?? 'text/plain'; + ctx.setCorsHeaders(res, origin); + const responseHeaders: Record = { 'Content-Type': contentType }; + if (sessionId) { + responseHeaders['x-session-id'] = sessionId; + } + res.writeHead(agentRes.statusCode ?? 200, responseHeaders); + agentRes.pipe(res); + agentRes.on('end', resolve); + agentRes.on('error', reject); + } + ); + + proxyReq.on('error', err => { + if (!res.headersSent) { + ctx.setCorsHeaders(res, origin); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Agent server error: ${err.message}` })); + } + resolve(); + }); + + proxyReq.write(body); + proxyReq.end(); + }); +} + +/** + * Handle invocation for A2A agents. + * Translates the frontend { prompt } payload into A2A JSON-RPC message/stream, + * proxies to the agent's root path, and transforms the A2A SSE response into + * the format useStreamingChat expects (data: "text"\n\n). + */ +async function handleA2AInvocation( + ctx: RouteContext, + res: ServerResponse, + rawBody: string, + agentPort: number, + sessionId?: string, + origin?: string +): Promise { + let prompt: string; + try { + const parsed = JSON.parse(rawBody) as { prompt?: string }; + prompt = parsed.prompt ?? ''; + } catch { + prompt = ''; + } + + if (!prompt) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'prompt is required' })); + return; + } + + const a2aBody = { + jsonrpc: '2.0', + id: a2aRequestId++, + method: 'message/stream', + params: { + message: { + messageId: randomUUID(), + role: 'user', + parts: [{ kind: 'text', text: prompt }], + ...(sessionId ? { contextId: sessionId } : {}), + }, + }, + }; + + let agentRes: Response; + try { + agentRes = await fetch(`http://127.0.0.1:${agentPort}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + body: JSON.stringify(a2aBody), + }); + } catch (err) { + ctx.setCorsHeaders(res, origin); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `A2A agent error: ${(err as Error).message}` })); + return; + } + + if (!agentRes.ok) { + ctx.setCorsHeaders(res, origin); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `A2A agent returned ${agentRes.status}` })); + return; + } + + const contentType = agentRes.headers.get('content-type') ?? ''; + ctx.setCorsHeaders(res, origin); + + // Streaming SSE response — transform A2A events to useStreamingChat format + if (contentType.includes('text/event-stream') && agentRes.body) { + const sseHeaders: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + if (sessionId) sseHeaders['x-session-id'] = sessionId; + res.writeHead(200, sseHeaders); + + const reader = (agentRes.body as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let streamedFromStatus = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (!data) continue; + + try { + const event = JSON.parse(data) as Record; + const text = extractSSEEventText(event, streamedFromStatus); + if (text) { + if (isStatusUpdateEvent(event)) streamedFromStatus = true; + res.write(`data: ${JSON.stringify(text)}\n\n`); + } + } catch { + res.write(`data: ${JSON.stringify(data)}\n\n`); + } + } + } + } finally { + reader.releaseLock(); + } + + res.end(); + return; + } + + // Non-streaming fallback: extract text from JSON-RPC result + const responseText = await agentRes.text(); + try { + const json = JSON.parse(responseText) as Record; + const result = json.result as Record | undefined; + const text = result ? (extractTaskText(result) ?? JSON.stringify(result, null, 2)) : responseText; + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write(`data: ${JSON.stringify(text)}\n\n`); + res.end(); + } catch { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(responseText); + } +} + +/** + * Handle invocation for AGUI agents. + * Translates the frontend { prompt } payload into AGUI RunAgentInput and + * proxies to the agent's /invocations path. The SSE response is passed + * through as-is — the frontend parses typed AG-UI events directly. + */ +async function handleAguiInvocation( + ctx: RouteContext, + res: ServerResponse, + rawBody: string, + agentPort: number, + sessionId?: string, + userId?: string, + origin?: string +): Promise { + let prompt: string; + try { + const parsed = JSON.parse(rawBody) as { prompt?: string }; + prompt = parsed.prompt ?? ''; + } catch { + prompt = ''; + } + + if (!prompt) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'prompt is required' })); + return; + } + + // Build RunAgentInput — the body format AGUI agents expect + const aguiBody = JSON.stringify({ + threadId: randomUUID(), + runId: randomUUID(), + messages: [{ id: randomUUID(), role: 'user', content: prompt }], + tools: [], + context: [], + state: {}, + forwardedProps: {}, + }); + + // Proxy to agent, piping the SSE response through untouched + return new Promise((resolve, reject) => { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }; + if (sessionId) { + headers['x-amzn-bedrock-agentcore-runtime-session-id'] = sessionId; + } + if (userId) { + headers['x-amzn-bedrock-agentcore-runtime-user-id'] = userId; + } + + const proxyReq = httpRequest( + { + hostname: '127.0.0.1', + port: agentPort, + path: '/invocations', + method: 'POST', + headers, + }, + agentRes => { + const contentType = agentRes.headers['content-type'] ?? 'text/plain'; + ctx.setCorsHeaders(res, origin); + const responseHeaders: Record = { 'Content-Type': contentType }; + if (sessionId) { + responseHeaders['x-session-id'] = sessionId; + } + res.writeHead(agentRes.statusCode ?? 200, responseHeaders); + agentRes.pipe(res); + agentRes.on('end', resolve); + agentRes.on('error', reject); + } + ); + + proxyReq.on('error', err => { + if (!res.headersSent) { + ctx.setCorsHeaders(res, origin); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `AGUI agent error: ${err.message}` })); + } + resolve(); + }); + + proxyReq.write(aguiBody); + proxyReq.end(); + }); +} diff --git a/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts b/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts new file mode 100644 index 000000000..bfbdb1b87 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts @@ -0,0 +1,82 @@ +import type { RouteContext } from './route-context.js'; +import type { IncomingMessage, ServerResponse } from 'http'; + +export async function handleMcpProxy( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + ctx.setCorsHeaders(res, origin); + + const raw = await ctx.readBody(req); + let parsed: { agentName?: string; body?: Record; sessionId?: string }; + try { + parsed = JSON.parse(raw) as typeof parsed; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Invalid JSON' })); + return; + } + + const { agentName, body, sessionId } = parsed; + + if (!agentName) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'agentName is required' })); + return; + } + + if (!body) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'body is required' })); + return; + } + + const running = ctx.runningAgents.get(agentName); + if (!running) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Agent "${agentName}" is not running` })); + return; + } + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }; + if (sessionId) { + headers['mcp-session-id'] = sessionId; + } + + let mcpRes: Response; + try { + mcpRes = await fetch(`http://localhost:${running.port}/mcp`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + } catch (err) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Failed to connect to MCP agent: ${(err as Error).message}` })); + return; + } + + if (!mcpRes.ok) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `MCP server returned status ${mcpRes.status}` })); + return; + } + + const responseText = await mcpRes.text(); + const responseSessionId = mcpRes.headers.get('mcp-session-id') ?? undefined; + + let result: unknown; + try { + result = JSON.parse(responseText); + } catch { + result = responseText; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, result, sessionId: responseSessionId })); +} diff --git a/src/cli/operations/dev/web-ui/handlers/memory.ts b/src/cli/operations/dev/web-ui/handlers/memory.ts new file mode 100644 index 000000000..5a01c9139 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/memory.ts @@ -0,0 +1,125 @@ +import type { RouteContext } from './route-context'; +import { parseRequestUrl } from './route-context'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz] + * Lists memory records. Requires onListMemoryRecords handler. + */ +export async function handleListMemoryRecords( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + if (!ctx.options.onListMemoryRecords) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Memory browsing is not available' })); + return; + } + + const { param } = parseRequestUrl(req); + const memoryName = param('memoryName'); + const namespace = param('namespace'); + const strategyId = param('strategyId'); + + if (!memoryName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'memoryName query parameter is required' })); + return; + } + + if (!namespace) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'namespace query parameter is required' })); + return; + } + + try { + const result = await ctx.options.onListMemoryRecords(memoryName, namespace, strategyId); + 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 memory records 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 memory records' })); + } +} + +/** + * POST /api/memory/search — semantic search across memory records. + * Body: { memoryName, namespace, searchQuery, strategyId? } + * Requires onRetrieveMemoryRecords handler. + */ +export async function handleRetrieveMemoryRecords( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + if (!ctx.options.onRetrieveMemoryRecords) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Memory search is not available' })); + return; + } + + const body = await ctx.readBody(req); + let memoryName: string | undefined; + let namespace: string | undefined; + let searchQuery: string | undefined; + let strategyId: string | undefined; + + try { + const parsed = JSON.parse(body) as { + memoryName?: string; + namespace?: string; + searchQuery?: string; + strategyId?: string; + }; + memoryName = parsed.memoryName; + namespace = parsed.namespace; + searchQuery = parsed.searchQuery; + strategyId = parsed.strategyId; + } catch { + // fall through + } + + if (!memoryName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'memoryName is required' })); + return; + } + + if (!namespace) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'namespace is required' })); + return; + } + + if (!searchQuery) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'searchQuery is required' })); + return; + } + + try { + const result = await ctx.options.onRetrieveMemoryRecords(memoryName, namespace, searchQuery, strategyId); + 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', `Retrieve memory records 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 search memory records' })); + } +} diff --git a/src/cli/operations/dev/web-ui/handlers/resources.ts b/src/cli/operations/dev/web-ui/handlers/resources.ts new file mode 100644 index 000000000..a9926ab08 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/resources.ts @@ -0,0 +1,293 @@ +import { ConfigIO } from '../../../../../lib'; +import type { DeployedState } from '../../../../../schema'; +import { computeResourceStatuses } from '../../../../commands/status/action'; +import { buildRuntimeInvocationUrl } from '../../../../commands/status/constants'; +import type { + ResourceAgent, + ResourceCredential, + ResourceDeploymentStatus, + ResourceEvaluator, + ResourceGateway, + ResourceMemory, + ResourceOnlineEvalConfig, + ResourcePolicyEngine, +} from '../api-types'; +import type { RouteContext } from './route-context'; +import type { ServerResponse } from 'node:http'; + +/** GET /api/resources — returns the full project resource graph from config files */ +export async function handleResources(ctx: RouteContext, res: ServerResponse, origin?: string): Promise { + const { configRoot, onLog } = ctx.options; + + if (!configRoot) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'No agentcore project found' })); + return; + } + + try { + const configIO = new ConfigIO({ baseDir: configRoot }); + const project = await configIO.readProjectSpec(); + + // Load deployed state to compute deployment statuses. + // Gracefully fall back to empty targets if no state file exists yet. + let deployedState: DeployedState = { targets: {} }; + if (configIO.configExists('state')) { + try { + deployedState = await configIO.readDeployedState(); + } catch { + onLog?.('warn', 'Failed to read deployed state'); + } + } + + // Pick the first target's resources for the diff (same heuristic as `agentcore status`) + const firstTargetKey = Object.keys(deployedState.targets)[0]; + const targetResources = firstTargetKey ? deployedState.targets[firstTargetKey]?.resources : undefined; + + // Read AWS targets to resolve region for invocation URLs. + let targetRegion: string | undefined; + try { + const awsTargets = await configIO.readAWSDeploymentTargets(); + const firstTarget = firstTargetKey ? awsTargets.find(t => t.name === firstTargetKey) : awsTargets[0]; + targetRegion = firstTarget?.region; + } catch { + // aws-targets.json may not exist yet — region will be undefined + } + + // Compute deployment statuses using the same logic as `agentcore status` + const resourceStatuses = computeResourceStatuses(project, targetResources); + const statusByTypeAndName = new Map(); + for (const entry of resourceStatuses) { + statusByTypeAndName.set(`${entry.resourceType}:${entry.name}`, entry.deploymentState); + } + + // Build agents from local config + const localAgentNames = new Set(project.runtimes.map(a => a.name)); + const agents: ResourceAgent[] = project.runtimes.map(a => { + const deployed = targetResources?.runtimes?.[a.name]; + return { + name: a.name, + build: a.build, + entrypoint: a.entrypoint, + codeLocation: a.codeLocation, + runtimeVersion: a.runtimeVersion ?? '', + networkMode: a.networkMode ?? 'PUBLIC', + protocol: a.protocol ?? 'HTTP', + envVars: a.envVars?.map(e => e.name) ?? [], + deploymentStatus: statusByTypeAndName.get(`agent:${a.name}`), + deployed, + invocationUrl: + deployed?.runtimeArn && targetRegion + ? buildRuntimeInvocationUrl(targetRegion, deployed.runtimeArn) + : undefined, + }; + }); + + // Add pending-removal agents (exist in deployed state but removed from local config) + for (const [name, deployed] of Object.entries(targetResources?.runtimes ?? {})) { + if (!localAgentNames.has(name)) { + agents.push({ + name, + build: '', + entrypoint: '', + codeLocation: '', + runtimeVersion: '', + networkMode: '', + protocol: '', + envVars: [], + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed, + invocationUrl: + deployed.runtimeArn && targetRegion + ? buildRuntimeInvocationUrl(targetRegion, deployed.runtimeArn) + : undefined, + }); + } + } + + // Build memories from local config + const localMemoryNames = new Set(project.memories.map(m => m.name)); + const memories: ResourceMemory[] = project.memories.map(m => ({ + name: m.name, + strategies: m.strategies.map(s => ({ + type: s.type, + namespaces: s.namespaces ?? [], + })), + expiryDays: m.eventExpiryDuration, + deploymentStatus: statusByTypeAndName.get(`memory:${m.name}`), + deployed: targetResources?.memories?.[m.name], + })); + + // Add pending-removal memories + for (const [name, deployed] of Object.entries(targetResources?.memories ?? {})) { + if (!localMemoryNames.has(name)) { + memories.push({ + name, + strategies: [], + expiryDays: undefined, + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed, + }); + } + } + + // Build credentials from local config + const localCredentialNames = new Set(project.credentials.map(c => c.name)); + const credentials: ResourceCredential[] = project.credentials.map(c => ({ + name: c.name, + type: c.authorizerType, + deploymentStatus: statusByTypeAndName.get(`credential:${c.name}`), + deployed: targetResources?.credentials?.[c.name], + })); + + // Add pending-removal credentials + for (const [name, deployed] of Object.entries(targetResources?.credentials ?? {})) { + if (!localCredentialNames.has(name)) { + credentials.push({ + name, + type: '', + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed, + }); + } + } + + // Build gateways from local config + const localGatewayNames = new Set((project.agentCoreGateways ?? []).map(g => g.name)); + const gateways: ResourceGateway[] = (project.agentCoreGateways ?? []).map(g => ({ + name: g.name, + targets: g.targets.map(t => ({ + name: t.toolDefinitions?.[0]?.name ?? t.name, + targetType: t.targetType, + })), + deploymentStatus: statusByTypeAndName.get(`gateway:${g.name}`), + deployed: targetResources?.mcp?.gateways?.[g.name], + })); + + // Add pending-removal gateways + for (const [name, deployed] of Object.entries(targetResources?.mcp?.gateways ?? {})) { + if (!localGatewayNames.has(name)) { + gateways.push({ + name, + targets: [], + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed, + }); + } + } + + const mcpRuntimeTools = (project.mcpRuntimeTools ?? []).map(t => ({ + name: t.name, + bindings: t.bindings ?? [], + })); + + // Build evaluators from local config + const localEvaluatorNames = new Set(project.evaluators.map(e => e.name)); + const evaluators: ResourceEvaluator[] = project.evaluators.map(e => ({ + name: e.name, + level: e.level, + description: e.description, + configType: e.config.codeBased ? ('code-based' as const) : ('llm-as-a-judge' as const), + deploymentStatus: statusByTypeAndName.get(`evaluator:${e.name}`), + deployed: targetResources?.evaluators?.[e.name], + })); + + // Add pending-removal evaluators + for (const [name, deployed] of Object.entries(targetResources?.evaluators ?? {})) { + if (!localEvaluatorNames.has(name)) { + evaluators.push({ + name, + level: '', + description: undefined, + configType: 'llm-as-a-judge' as const, + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed, + }); + } + } + + // Build online eval configs from local config + const localOnlineEvalNames = new Set(project.onlineEvalConfigs.map(o => o.name)); + const onlineEvalConfigs: ResourceOnlineEvalConfig[] = project.onlineEvalConfigs.map(o => ({ + name: o.name, + agent: o.agent, + evaluators: o.evaluators, + samplingRate: o.samplingRate, + description: o.description, + deploymentStatus: statusByTypeAndName.get(`online-eval:${o.name}`), + deployed: targetResources?.onlineEvalConfigs?.[o.name], + })); + + // Add pending-removal online eval configs + for (const [name, deployed] of Object.entries(targetResources?.onlineEvalConfigs ?? {})) { + if (!localOnlineEvalNames.has(name)) { + onlineEvalConfigs.push({ + name, + agent: '', + evaluators: [], + samplingRate: 0, + description: undefined, + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed, + }); + } + } + + // Build policy engines from local config + const localPolicyEngineNames = new Set(project.policyEngines.map(pe => pe.name)); + const policyEngines: ResourcePolicyEngine[] = project.policyEngines.map(pe => ({ + name: pe.name, + description: pe.description, + policies: pe.policies.map(p => ({ + name: p.name, + description: p.description, + deploymentStatus: statusByTypeAndName.get(`policy:${pe.name}/${p.name}`), + deployed: targetResources?.policies?.[`${pe.name}/${p.name}`] ?? targetResources?.policies?.[p.name], + })), + deploymentStatus: statusByTypeAndName.get(`policy-engine:${pe.name}`), + deployed: targetResources?.policyEngines?.[pe.name], + })); + + // Add pending-removal policy engines + for (const [name, deployed] of Object.entries(targetResources?.policyEngines ?? {})) { + if (!localPolicyEngineNames.has(name)) { + policyEngines.push({ + name, + description: undefined, + policies: [], + deploymentStatus: 'pending-removal' as ResourceDeploymentStatus, + deployed, + }); + } + } + + const unassignedTargets = (project.unassignedTargets ?? []).map(t => ({ + name: t.name, + targetType: t.targetType, + })); + + ctx.setCorsHeaders(res, origin); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: true, + project: project.name, + agents, + memories, + credentials, + gateways, + mcpRuntimeTools, + evaluators, + onlineEvalConfigs, + policyEngines, + unassignedTargets, + }) + ); + } catch (err) { + onLog?.('error', `Failed to read resources: ${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 read project configuration' })); + } +} diff --git a/src/cli/operations/dev/web-ui/handlers/route-context.ts b/src/cli/operations/dev/web-ui/handlers/route-context.ts new file mode 100644 index 000000000..f9e2e030f --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/route-context.ts @@ -0,0 +1,41 @@ +import type { DevServer } from '../../server'; +import type { AgentError } from '../constants'; +import type { WebUIOptions } from '../web-server'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * Shared context passed to every route handler. + * Provides access to server options, running agent state, and HTTP helpers. + */ +export interface RouteContext { + readonly options: WebUIOptions; + /** Map of agentName → running agent server + port */ + readonly runningAgents: Map; + /** Map of agentName → in-flight start promise (prevents duplicate starts) */ + readonly startingAgents: Map>; + /** Map of agentName → error state (set when an agent fails to start or crashes) */ + readonly agentErrors: Map; + /** Set CORS headers on the response */ + setCorsHeaders(res: ServerResponse, origin?: string): void; + /** Read the full request body as a string */ + readBody(req: IncomingMessage): Promise; +} + +/** + * Parse the URL from an incoming request and return the pathname and a + * helper to read query-string parameters. + * + * Usage: + * const { pathname, param } = parseRequestUrl(req); + * const agentName = param('agentName'); // string or undefined + */ +export function parseRequestUrl(req: IncomingMessage): { + pathname: string; + param: (name: string) => string | undefined; +} { + const url = new URL(req.url ?? '', `http://${req.headers.host}`); + return { + pathname: url.pathname, + param: (name: string) => url.searchParams.get(name) ?? undefined, + }; +} diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts new file mode 100644 index 000000000..41857bda1 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -0,0 +1,186 @@ +import { type DevServerCallbacks, createDevServer, findAvailablePort } from '../../server'; +import { waitForServerReady } from '../../utils'; +import type { RouteContext } from './route-context'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * POST /api/start — start an agent server on demand. + * Body: { agentName: string } + */ +export async function handleStart( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const body = await ctx.readBody(req); + let agentName: string | undefined; + try { + const parsed = JSON.parse(body) as { agentName?: string }; + agentName = parsed.agentName; + } catch { + // fall through + } + + if (!agentName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'agentName is required' })); + return; + } + + // Delegate to custom start handler if provided + if (ctx.options.onStart) { + const result = await ctx.options.onStart(agentName); + ctx.setCorsHeaders(res, origin); + res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + return; + } + + // Already running — return existing port + const existing = ctx.runningAgents.get(agentName); + if (existing) { + ctx.setCorsHeaders(res, origin); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, name: agentName, port: existing.port })); + return; + } + + // If a start is already in flight for this agent, wait for it instead of spawning a duplicate + const inflight = ctx.startingAgents.get(agentName); + if (inflight) { + const result = await inflight; + ctx.setCorsHeaders(res, origin); + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + return; + } + + const startPromise = doStartAgent(ctx, agentName); + ctx.startingAgents.set(agentName, startPromise); + + try { + const result = await startPromise; + ctx.setCorsHeaders(res, origin); + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } finally { + ctx.startingAgents.delete(agentName); + } +} + +/** + * Actually start an agent server. Extracted so the result + * can be shared across concurrent requests via startingAgents. + */ +async function doStartAgent( + ctx: RouteContext, + agentName: string +): Promise<{ success: boolean; name: string; port: number; error?: string }> { + const getDevConfig = ctx.options.getDevConfig; + if (!getDevConfig) { + return { success: false, name: agentName, port: 0, error: 'Dev config factory not provided' }; + } + + const config = await getDevConfig(agentName); + if (!config) { + return { success: false, name: agentName, port: 0, error: `Agent "${agentName}" not found or not supported` }; + } + + const agentIndex = ctx.options.agents.findIndex(a => a.name === agentName); + const { onLog } = ctx.options; + + // A2A agents use a fixed framework port (9000) that can't be overridden via env vars — + // serve_a2a() accepts port as a function parameter, not from the environment. + // MCP agents (FastMCP) also use a fixed port: FastMCP.__init__ passes port=8000 as a + // pydantic BaseSettings init kwarg, which takes priority over the FASTMCP_PORT env var + // we set. So MCP agents always bind to 8000 regardless of environment configuration. + const isA2A = config.protocol === 'A2A'; + const isMCP = config.protocol === 'MCP'; + const targetPort = isA2A ? 9000 : isMCP ? 8000 : ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0); + const agentPort = await findAvailablePort(targetPort); + if (isA2A && agentPort !== 9000) { + return { + success: false, + name: agentName, + port: 0, + error: `Port 9000 is in use. A2A agents require port 9000.`, + }; + } + if (isMCP && agentPort !== 8000) { + return { + success: false, + name: agentName, + port: 0, + error: `Port 8000 is in use. MCP agents require port 8000 (FastMCP default).`, + }; + } + if (agentPort !== targetPort) { + onLog?.('info', `[${agentName}] Port ${targetPort} in use, using ${agentPort}`); + } + + // Collect error messages during startup so we can surface them to the frontend + const errorMessages: string[] = []; + + const callbacks: DevServerCallbacks = { + onLog: (level, msg) => { + if (level === 'error') { + errorMessages.push(msg); + // Surface runtime errors to the frontend via /api/status. + // Only update if the agent is already running (startup errors are + // handled separately when start() returns null). + if (ctx.runningAgents.has(agentName)) { + ctx.agentErrors.set(agentName, { message: msg, timestamp: Date.now() }); + } + } + onLog?.(level === 'error' ? 'error' : 'info', `[${agentName}] ${msg}`); + }, + onExit: code => { + onLog?.('info', `[${agentName}] Server exited with code ${code ?? 0}`); + ctx.runningAgents.delete(agentName); + // Record error state when the server crashes after it was running + if (code !== 0 && code !== null) { + ctx.agentErrors.set(agentName, { + message: errorMessages.length > 0 ? errorMessages.join('\n') : `Server exited with code ${code}`, + timestamp: Date.now(), + }); + } + }, + }; + + const baseEnvVars = ctx.options.getEnvVars ? await ctx.options.getEnvVars() : (ctx.options.envVars ?? {}); + const agentEnvVars = { ...baseEnvVars, OTEL_SERVICE_NAME: agentName }; + + const agentServer = createDevServer(config, { + port: agentPort, + envVars: agentEnvVars, + callbacks, + }); + + // Clear any previous error for this agent before attempting start + ctx.agentErrors.delete(agentName); + + const child = await agentServer.start(); + + // start() returns null when prepare() fails (e.g. Docker not ready, missing Dockerfile) + if (!child) { + const errorMsg = errorMessages.length > 0 ? errorMessages.join('\n') : 'Agent server failed to start'; + ctx.agentErrors.set(agentName, { message: errorMsg, timestamp: Date.now() }); + return { success: false, name: agentName, port: 0, error: errorMsg }; + } + + // 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 = + errorMessages.length > 0 ? errorMessages.join('\n') : 'Agent server started but is not accepting connections'; + ctx.agentErrors.set(agentName, { message: errorMsg, timestamp: Date.now() }); + 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/handlers/status.ts b/src/cli/operations/dev/web-ui/handlers/status.ts new file mode 100644 index 000000000..0fdb05500 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/status.ts @@ -0,0 +1,25 @@ +import type { StatusAgentError, StatusRunningAgent } from '../api-types'; +import type { RouteContext } from './route-context'; +import type { ServerResponse } from 'node:http'; + +/** GET /api/status — returns available agents, which ones are running, and any errors */ +export function handleStatus(ctx: RouteContext, res: ServerResponse, origin?: string): void { + const { agents } = ctx.options; + const running: StatusRunningAgent[] = []; + + for (const [name, { port }] of ctx.runningAgents) { + running.push({ name, port }); + } + + // Collect per-agent errors + const errors: StatusAgentError[] = []; + for (const [name, agentError] of ctx.agentErrors) { + errors.push({ name, message: agentError.message }); + } + + ctx.setCorsHeaders(res, origin); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ mode: ctx.options.mode, agents, running, errors, selectedAgent: ctx.options.selectedAgent }) + ); +} diff --git a/src/cli/operations/dev/web-ui/handlers/traces.ts b/src/cli/operations/dev/web-ui/handlers/traces.ts new file mode 100644 index 000000000..e08b37111 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/traces.ts @@ -0,0 +1,119 @@ +import type { RouteContext } from './route-context'; +import { parseRequestUrl } from './route-context'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * GET /api/traces?agentName=xxx — list recent traces. + * Returns local OTEL traces when the collector is active. + */ +export async function handleListTraces( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { param } = parseRequestUrl(req); + const handler = ctx.options.onListTraces; + + if (!handler) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Traces are not available' })); + return; + } + + const agentName = param('agentName'); + + // 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, 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 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 traces' })); + } +} + +/** + * GET /api/traces/:traceId?agentName=xxx — get full trace data. + * Returns local OTEL trace spans and logs when the collector is active. + */ +export async function handleGetTrace( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { pathname, param } = parseRequestUrl(req); + const handler = ctx.options.onGetTrace; + + if (!handler) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Traces are not available' })); + return; + } + + const traceId = pathname.replace('/api/traces/', ''); + const agentName = param('agentName'); + + 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; + } + + // 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, 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 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 trace' })); + } +} diff --git a/src/cli/operations/dev/web-ui/index.ts b/src/cli/operations/dev/web-ui/index.ts new file mode 100644 index 000000000..6901eb31a --- /dev/null +++ b/src/cli/operations/dev/web-ui/index.ts @@ -0,0 +1,37 @@ +export { + WebUIServer, + type WebUIOptions, + type StartHandler, + type ListTracesHandler, + type GetTraceHandler, + type ListMemoryRecordsHandler, + type RetrieveMemoryRecordsHandler, +} from './web-server'; +export { runWebUI, type RunWebUIOptions } from './run-web-ui'; +export { WEB_UI_LOCAL_URL, WEB_UI_DEFAULT_PORT, type AgentInfo, type RunningAgent, type AgentError } from './constants'; +export type { + ResourceDeploymentStatus, + StatusResponse, + StatusAgent, + StatusRunningAgent, + StatusAgentError, + ResourcesResponse, + ResourceAgent, + ResourceMemory, + ResourceMemoryStrategy, + ResourceCredential, + ResourceGateway, + ResourceGatewayTarget, + ResourceMcpTool, + ResourceMcpToolBinding, + StartRequest, + StartResponse, + InvocationRequest, + ListTracesResponse, + GetTraceResponse, + ListMemoryRecordsResponse, + MemoryRecordResponse, + RetrieveMemoryRecordsRequest, + RetrieveMemoryRecordsResponse, + ApiErrorResponse, +} from './api-types'; diff --git a/src/cli/operations/dev/web-ui/run-web-ui.ts b/src/cli/operations/dev/web-ui/run-web-ui.ts new file mode 100644 index 000000000..2cce51eee --- /dev/null +++ b/src/cli/operations/dev/web-ui/run-web-ui.ts @@ -0,0 +1,60 @@ +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'; + +export interface RunWebUIOptions { + /** Options to pass to WebUIServer (minus uiPort, which is resolved automatically) */ + serverOptions: Omit; + /** Logger command label (e.g. 'dev') */ + logLabel: string; + /** Optional log handler override. Defaults to console logging errors. */ + onLog?: (level: 'info' | 'warn' | 'error', message: string) => void; +} + +/** + * Shared entry point for launching the web UI. + * Handles port discovery, logger setup, browser launch, SIGINT, and keep-alive. + */ +export async function runWebUI(opts: RunWebUIOptions): Promise { + const { serverOptions, logLabel } = opts; + + const logger = new ExecLogger({ command: logLabel }); + const uiPort = await findAvailablePort(WEB_UI_DEFAULT_PORT); + if (uiPort !== WEB_UI_DEFAULT_PORT) { + console.log(`Port ${WEB_UI_DEFAULT_PORT} in use, using ${uiPort}`); + } + + console.log(`Starting web UI...`); + console.log(`Log: ${logger.getRelativeLogPath()}`); + + const onLog = + opts.onLog ?? + ((level: 'info' | 'warn' | 'error', msg: string) => { + if (level === 'error') console.error(`Web UI: ${msg}`); + }); + + const webUI = new WebUIServer({ + ...serverOptions, + uiPort, + onReady: url => { + const chatUrl = url; + console.log(`\nChat UI: ${chatUrl}`); + console.log(`Press Ctrl+C to stop\n`); + openBrowser(chatUrl); + }, + onLog, + }); + + webUI.start(); + + process.on('SIGINT', () => { + console.log('\nStopping servers...'); + webUI.stop(); + }); + + // Keep process alive + // eslint-disable-next-line @typescript-eslint/no-empty-function + await new Promise(() => {}); +} diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts new file mode 100644 index 000000000..c3f9c6f36 --- /dev/null +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -0,0 +1,374 @@ +import type { DevConfig } from '../config'; +import type { DevServer } from '../server'; +import { type AgentError, type AgentInfo, WEB_UI_LOCAL_URL } from './constants'; +import { + type RouteContext, + handleA2AAgentCard, + handleGetTrace, + handleInvocations, + handleListMemoryRecords, + handleListTraces, + handleMcpProxy, + handleResources, + handleRetrieveMemoryRecords, + handleStart, + handleStatus, +} from './handlers'; +import fs from 'node:fs'; +import { type IncomingMessage, type ServerResponse, createServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.map': 'application/json', + '.svg': 'image/svg+xml', +}; + +/** CSP header to block inline script injection from malicious agent responses. */ +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. */ +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; + } + return null; +} + +/** + * Custom handler for POST /api/start. + * Return a JSON-serializable response object. Throwing an error will send a 500. + */ +export type StartHandler = ( + agentName: string +) => Promise<{ success: boolean; name: string; port: number; error?: string }>; + +/** + * Custom handler for GET /api/traces. + * Returns a list of recent traces for the given agent. + */ +export type ListTracesHandler = ( + agentName: string | undefined, + startTime?: number, + endTime?: number +) => Promise<{ success: boolean; traces?: unknown[]; error?: string }>; + +/** + * Custom handler for GET /api/traces/:traceId. + * Returns the full trace data for a specific trace. + */ +export type GetTraceHandler = ( + agentName: string | undefined, + traceId: string, + startTime?: number, + endTime?: number +) => Promise<{ success: boolean; resourceSpans?: unknown[]; resourceLogs?: unknown[]; error?: string }>; + +/** + * Custom handler for GET /api/memory. + * Returns a list of memory records for a given memory + namespace. + */ +export type ListMemoryRecordsHandler = ( + memoryName: string, + namespace: string, + strategyId?: string +) => Promise<{ success: boolean; records?: unknown[]; error?: string }>; + +/** + * Custom handler for POST /api/memory/search. + * Performs semantic search across memory records. + */ +export type RetrieveMemoryRecordsHandler = ( + memoryName: string, + namespace: string, + searchQuery: string, + strategyId?: string +) => Promise<{ success: boolean; records?: unknown[]; error?: string }>; + +export interface WebUIOptions { + /** Server mode identifier (currently only 'dev' is used) */ + mode: 'dev'; + /** Port for the web UI server (API proxy) */ + uiPort: number; + /** Available agents (metadata only — servers are started on demand) */ + agents: AgentInfo[]; + /** Dev config factory — called when an agent needs to be started. Required for dev mode, unused when onStart is provided. */ + getDevConfig?: (agentName: string) => DevConfig | null | Promise; + /** Env vars to pass to started agent servers */ + envVars?: Record; + /** Callback to reload env vars from .env.local. When provided, called on each agent start to pick up new keys. */ + getEnvVars?: () => Promise>; + /** Path to the agentcore/ config directory */ + configRoot?: string; + /** Callback when server starts listening */ + onReady?: (url: string) => void; + /** Callback for log messages */ + onLog?: (level: 'info' | 'warn' | 'error', message: string) => void; + /** Custom start handler — overrides the default dev server start logic */ + onStart?: StartHandler; + /** Custom handler for listing traces */ + onListTraces?: ListTracesHandler; + /** Custom handler for getting a single trace */ + onGetTrace?: GetTraceHandler; + /** Custom handler for listing memory records */ + onListMemoryRecords?: ListMemoryRecordsHandler; + /** Custom handler for searching memory records */ + onRetrieveMemoryRecords?: RetrieveMemoryRecordsHandler; + /** Agent to pre-select in the UI dropdown (set when --runtime is specified) */ + selectedAgent?: string; + /** Callback to reload the agents list from config. When provided, the server watches agentcore.json and calls this on change. */ + reloadAgents?: () => Promise; +} + +/** + * Lightweight HTTP server that proxies requests to agent dev servers. + * Agent servers are started on demand when the frontend selects an agent. + * The chat UI is served as static files from the built frontend (agent-inspector). + * + * Route handlers are in ./handlers/ — this class owns lifecycle, CORS, and routing only. + */ +export class WebUIServer { + private server: ReturnType | null = null; + private configWatcher: fs.FSWatcher | null = null; + /** Map of agentName → running agent server + port */ + private readonly runningAgents = new Map(); + /** Map of agentName → in-flight start promise (prevents duplicate starts from concurrent requests) */ + private readonly startingAgents = new Map< + string, + Promise<{ success: boolean; name: string; port: number; error?: string }> + >(); + /** Map of agentName → error state (set when an agent fails to start or crashes) */ + private readonly agentErrors = new Map(); + + constructor(private readonly options: WebUIOptions) {} + + /** Build a RouteContext that handlers use to access shared state */ + private get ctx(): RouteContext { + return { + options: this.options, + runningAgents: this.runningAgents, + startingAgents: this.startingAgents, + agentErrors: this.agentErrors, + setCorsHeaders: (res, origin) => this.setCorsHeaders(res, origin), + readBody: req => this.readBody(req), + }; + } + + start(): void { + const { uiPort, onReady, onLog } = this.options; + const webUiBaseUrl = `http://localhost:${uiPort}`; + + this.server = createServer((req: IncomingMessage, res: ServerResponse) => { + void (async () => { + // DNS rebinding protection — reject requests where the Host header + // is not localhost/127.0.0.1. An attacker could use a custom domain + // that resolves to 127.0.0.1, which would bypass origin checks since + // the browser considers it a different origin. + const host = (req.headers.host ?? '').replace(/:\d+$/, ''); + if (host !== 'localhost' && host !== '127.0.0.1') { + onLog?.('warn', `Blocked request with unexpected Host: ${req.headers.host}`); + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Forbidden'); + return; + } + + const origin = req.headers.origin; + + // Server-side origin validation — reject cross-origin requests from + // origins not in the allowlist before any handler logic executes. + // This is critical because CORS headers alone only prevent the browser + // from reading responses; the server still processes the request and + // executes side effects (starting agents, invoking with AWS credentials). + if (origin && !this.allowedOrigins.includes(origin)) { + onLog?.('warn', `Blocked cross-origin request from ${origin}`); + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Forbidden'); + return; + } + + // Handle CORS preflight + if (req.method === 'OPTIONS') { + this.setCorsHeaders(res, origin); + res.writeHead(204); + res.end(); + return; + } + + // Require a custom header on all POST requests. This forces browsers + // to send a CORS preflight (which our origin check blocks for cross- + // origin callers), closing the gap where simple form POSTs bypass + // preflight and may omit the Origin header entirely. + if (req.method === 'POST' && !req.headers['x-agentcore-local']) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Forbidden: missing X-Agentcore-Local header'); + return; + } + + try { + await this.route(req, res, origin); + } catch (err) { + onLog?.('error', `Request error: ${err instanceof Error ? err.message : String(err)}`); + if (!res.headersSent) { + this.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + } + } + })(); + }); + + this.server.listen(uiPort, '127.0.0.1', () => { + onReady?.(webUiBaseUrl); + }); + + this.server.on('error', (err: Error) => { + onLog?.('error', `Web UI server error: ${err.message}`); + }); + + this.startConfigWatcher(); + } + + stop(): void { + this.configWatcher?.close(); + this.configWatcher = null; + for (const { server } of this.runningAgents.values()) { + server.kill(); + } + this.runningAgents.clear(); + this.server?.close(); + this.server = null; + } + + /** + * Watch agentcore.json for changes and reload the agents list. + * Only active when both configRoot and reloadAgents are provided. + */ + private startConfigWatcher(): void { + const { configRoot, reloadAgents, onLog } = this.options; + if (!configRoot || !reloadAgents) return; + + const configPath = path.join(configRoot, 'agentcore.json'); + try { + this.configWatcher = fs.watch(configPath, () => { + void reloadAgents().then( + agents => { + this.options.agents = agents; + onLog?.('info', `Reloaded agents from agentcore.json (${agents.length} agent(s))`); + }, + err => { + onLog?.('warn', `Failed to reload agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + ); + }); + } catch (err) { + onLog?.('warn', `Could not watch agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** Route an incoming request to the appropriate handler */ + private async route(req: IncomingMessage, res: ServerResponse, origin?: string): Promise { + const ctx = this.ctx; + + if (req.method === 'GET' && req.url === '/api/status') { + handleStatus(ctx, res, origin); + } else if (req.method === 'GET' && req.url === '/api/resources') { + await handleResources(ctx, res, origin); + } else if (req.method === 'GET' && req.url?.startsWith('/api/traces/')) { + 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 === 'POST' && req.url === '/api/start') { + await handleStart(ctx, req, res, origin); + } else if (req.method === 'POST' && req.url === '/invocations') { + await handleInvocations(ctx, req, res, origin); + } else if (req.method === 'POST' && req.url === '/api/mcp') { + await handleMcpProxy(ctx, req, res, origin); + } else if (req.method === 'GET' && req.url?.startsWith('/api/a2a/agent-card')) { + await handleA2AAgentCard(ctx, req, res, origin); + } else if (req.method === 'GET' && req.url?.startsWith('/api/memory')) { + await handleListMemoryRecords(ctx, req, res, origin); + } else if (req.method === 'POST' && req.url === '/api/memory/search') { + await handleRetrieveMemoryRecords(ctx, req, res, origin); + } else if (req.method === 'GET' && this.serveStaticFile(req, res)) { + // Served a static frontend file + } else { + this.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } + } + + /** Serve a static file from the frontend dist directory. Returns true if served. */ + private serveStaticFile(req: IncomingMessage, res: ServerResponse): boolean { + const distDir = resolveUIDistDir(); + if (!distDir) return false; + + const urlPath = req.url?.split('?')[0] ?? '/'; + const ext = path.extname(urlPath); + + // Serve the exact file if it has a known extension and exists + if (ext && MIME_TYPES[ext]) { + const filePath = path.join(distDir, urlPath); + if (!filePath.startsWith(distDir)) return false; + if (fs.existsSync(filePath)) { + const headers: Record = { 'Content-Type': MIME_TYPES[ext] }; + if (ext === '.html') headers['Content-Security-Policy'] = CSP_HEADER; + res.writeHead(200, headers); + fs.createReadStream(filePath).pipe(res); + return true; + } + } + + // SPA fallback: serve index.html for all other paths + const indexPath = path.join(distDir, 'index.html'); + if (fs.existsSync(indexPath)) { + res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Security-Policy': CSP_HEADER }); + fs.createReadStream(indexPath).pipe(res); + return true; + } + + return false; + } + + /** Origins that are allowed to make cross-origin requests to this server. */ + private get allowedOrigins(): string[] { + const { uiPort } = this.options; + return [ + `http://localhost:${uiPort}`, + WEB_UI_LOCAL_URL, // Vite dev server for frontend HMR workflow + ]; + } + + private setCorsHeaders(res: ServerResponse, origin?: string): void { + const fallback = this.allowedOrigins[0] ?? WEB_UI_LOCAL_URL; + const allowedOrigin = origin && this.allowedOrigins.includes(origin) ? origin : fallback; + res.setHeader('Access-Control-Allow-Origin', allowedOrigin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Agentcore-Local, Mcp-Session-Id'); + res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, x-session-id'); + res.setHeader('Vary', 'Origin'); + } + + private readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); + req.on('error', reject); + }); + } +} 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/memory/index.ts b/src/cli/operations/memory/index.ts new file mode 100644 index 000000000..5223117ee --- /dev/null +++ b/src/cli/operations/memory/index.ts @@ -0,0 +1,11 @@ +export { + listMemoryRecords, + type MemoryRecordEntry, + type ListMemoryRecordsOptions, + type ListMemoryRecordsResult, +} from './list-memory-records'; +export { + retrieveMemoryRecords, + type RetrieveMemoryRecordsOptions, + type RetrieveMemoryRecordsResult, +} from './retrieve-memory-records'; diff --git a/src/cli/operations/memory/list-memory-records.ts b/src/cli/operations/memory/list-memory-records.ts new file mode 100644 index 000000000..8bbf34628 --- /dev/null +++ b/src/cli/operations/memory/list-memory-records.ts @@ -0,0 +1,70 @@ +import { getCredentialProvider } from '../../aws'; +import { BedrockAgentCoreClient, ListMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; + +export interface MemoryRecordEntry { + memoryRecordId: string; + content: string | undefined; + memoryStrategyId: string; + namespaces: string[]; + createdAt: string; + score: number | undefined; + metadata: Record; +} + +export interface ListMemoryRecordsOptions { + region: string; + memoryId: string; + namespace: string; + memoryStrategyId?: string; + maxResults?: number; + nextToken?: string; +} + +export interface ListMemoryRecordsResult { + success: boolean; + records?: MemoryRecordEntry[]; + nextToken?: string; + error?: string; +} + +/** + * Lists memory records for a deployed memory resource via the AWS SDK. + */ +export async function listMemoryRecords(options: ListMemoryRecordsOptions): Promise { + const { region, memoryId, namespace, memoryStrategyId, maxResults = 50, nextToken } = options; + + const client = new BedrockAgentCoreClient({ + region, + credentials: getCredentialProvider(), + }); + + try { + const response = await client.send( + new ListMemoryRecordsCommand({ + memoryId, + namespace, + memoryStrategyId, + maxResults, + nextToken, + }) + ); + + const records: MemoryRecordEntry[] = (response.memoryRecordSummaries ?? []).map(r => ({ + memoryRecordId: r.memoryRecordId ?? 'unknown', + content: r.content?.text, + memoryStrategyId: r.memoryStrategyId ?? 'unknown', + namespaces: r.namespaces ?? [], + createdAt: r.createdAt?.toISOString() ?? 'unknown', + score: r.score, + metadata: Object.fromEntries(Object.entries(r.metadata ?? {}).map(([k, v]) => [k, v?.stringValue ?? ''])), + })); + + return { success: true, records, nextToken: response.nextToken }; + } 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: err.message ?? String(error) }; + } +} diff --git a/src/cli/operations/memory/retrieve-memory-records.ts b/src/cli/operations/memory/retrieve-memory-records.ts new file mode 100644 index 000000000..e8d2a65ed --- /dev/null +++ b/src/cli/operations/memory/retrieve-memory-records.ts @@ -0,0 +1,69 @@ +import { getCredentialProvider } from '../../aws'; +import type { MemoryRecordEntry } from './list-memory-records'; +import { BedrockAgentCoreClient, RetrieveMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; + +export interface RetrieveMemoryRecordsOptions { + region: string; + memoryId: string; + namespace: string; + searchQuery: string; + memoryStrategyId?: string; + topK?: number; + maxResults?: number; + nextToken?: string; +} + +export interface RetrieveMemoryRecordsResult { + success: boolean; + records?: MemoryRecordEntry[]; + nextToken?: string; + error?: string; +} + +/** + * Searches memory records using semantic retrieval via the AWS SDK. + */ +export async function retrieveMemoryRecords( + options: RetrieveMemoryRecordsOptions +): Promise { + const { region, memoryId, namespace, searchQuery, memoryStrategyId, topK, maxResults, nextToken } = options; + + const client = new BedrockAgentCoreClient({ + region, + credentials: getCredentialProvider(), + }); + + try { + const response = await client.send( + new RetrieveMemoryRecordsCommand({ + memoryId, + namespace, + searchCriteria: { + searchQuery, + memoryStrategyId, + topK, + }, + maxResults, + nextToken, + }) + ); + + const records: MemoryRecordEntry[] = (response.memoryRecordSummaries ?? []).map(r => ({ + memoryRecordId: r.memoryRecordId ?? 'unknown', + content: r.content?.text, + memoryStrategyId: r.memoryStrategyId ?? 'unknown', + namespaces: r.namespaces ?? [], + createdAt: r.createdAt?.toISOString() ?? 'unknown', + score: r.score, + metadata: Object.fromEntries(Object.entries(r.metadata ?? {}).map(([k, v]) => [k, v?.stringValue ?? ''])), + })); + + return { success: true, records, nextToken: response.nextToken }; + } 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: err.message ?? String(error) }; + } +} 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/operations/traces/trace-url.ts b/src/cli/operations/traces/trace-url.ts index ceb0512c6..a28791e3c 100644 --- a/src/cli/operations/traces/trace-url.ts +++ b/src/cli/operations/traces/trace-url.ts @@ -1,3 +1,4 @@ +import { arnPrefix, consoleDomain } from '../../aws/partition'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; /** @@ -11,7 +12,7 @@ export function buildTraceConsoleUrl(params: { }): string { const { region, accountId, runtimeId, agentName } = params; const resourceId = encodeURIComponent( - `arn:aws:bedrock-agentcore:${region}:${accountId}:runtime/${runtimeId}/runtime-endpoint/${DEFAULT_ENDPOINT_NAME}:${DEFAULT_ENDPOINT_NAME}` + `${arnPrefix(region)}:bedrock-agentcore:${region}:${accountId}:runtime/${runtimeId}/runtime-endpoint/${DEFAULT_ENDPOINT_NAME}:${DEFAULT_ENDPOINT_NAME}` ); - return `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#/gen-ai-observability/agent-core/agent-alias/${runtimeId}/endpoint/${DEFAULT_ENDPOINT_NAME}/agent/${agentName}?start=-43200000&resourceId=${resourceId}&serviceName=${agentName}.${DEFAULT_ENDPOINT_NAME}&tabId=traces`; + return `https://${region}.${consoleDomain(region)}/cloudwatch/home?region=${region}#/gen-ai-observability/agent-core/agent-alias/${runtimeId}/endpoint/${DEFAULT_ENDPOINT_NAME}/agent/${agentName}?start=-43200000&resourceId=${resourceId}&serviceName=${agentName}.${DEFAULT_ENDPOINT_NAME}&tabId=traces`; } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index b30dfe5e1..4702633ed 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -12,9 +12,16 @@ import type { SDKFramework, TargetLanguage, } from '../../schema'; -import { AgentEnvSpecSchema, CREDENTIAL_PROVIDERS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../schema'; +import { + AgentEnvSpecSchema, + CREDENTIAL_PROVIDERS, + DEFAULT_PYTHON_VERSION, + LIFECYCLE_TIMEOUT_MAX, + LIFECYCLE_TIMEOUT_MIN, +} from '../../schema'; import type { AddAgentOptions as CLIAddAgentOptions } from '../commands/add/types'; import { validateAddAgentOptions } from '../commands/add/validate'; +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'; @@ -28,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'; @@ -51,6 +59,7 @@ export interface AddAgentOptions extends VpcOptions { apiKey?: string; memory?: MemoryOption; protocol?: ProtocolMode; + requestHeaderAllowlist?: string[]; codeLocation?: string; entrypoint?: string; bedrockAgentId?: string; @@ -66,6 +75,7 @@ export interface AddAgentOptions extends VpcOptions { clientSecret?: string; idleTimeout?: number; maxLifetime?: number; + sessionStorageMountPath?: string; } /** @@ -106,7 +116,10 @@ export class AgentPrimitive extends BasePrimitive agent.name === options.name); if (existingAgent) { - return { success: false, error: `Agent "${options.name}" already exists in this project.` }; + return { + success: false, + error: `Agent "${options.name}" already exists. To update its configuration, edit agentcore/agentcore.json directly.`, + }; } if (options.type === 'import') { @@ -207,7 +220,7 @@ export class AgentPrimitive extends BasePrimitive', 'Model provider: Bedrock, Anthropic, OpenAI, Gemini [non-interactive]') .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') .option('--memory ', 'Memory: none, shortTerm, longAndShortTerm (create path only) [non-interactive]') - .option('--protocol ', 'Protocol: HTTP, MCP, A2A (default: HTTP) [non-interactive]') + .option('--protocol ', 'Protocol: HTTP, MCP, A2A, AGUI (default: HTTP) [non-interactive]') .option('--code-location ', 'Path to existing code (BYO path only) [non-interactive]') .option('--entrypoint ', 'Entry file relative to code-location (BYO, default: main.py) [non-interactive]') .option('--agent-id ', 'Bedrock Agent ID (import path only) [non-interactive]') @@ -224,6 +237,10 @@ export class AgentPrimitive extends BasePrimitive', 'Custom claim validations as JSON array (for CUSTOM_JWT) [non-interactive]') .option('--client-id ', 'OAuth client ID for agent bearer token [non-interactive]') .option('--client-secret ', 'OAuth client secret [non-interactive]') + .option( + '--request-header-allowlist ', + 'Comma-separated list of custom header names to allow (auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom-) [non-interactive]' + ) .option( '--idle-timeout ', `Idle session timeout in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` @@ -232,6 +249,10 @@ export class AgentPrimitive extends BasePrimitive', `Max instance lifetime in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` ) + .option( + '--session-storage-mount-path ', + 'Absolute mount path for session filesystem storage (e.g. /mnt/session-storage) [non-interactive]' + ) .option('--json', 'Output as JSON [non-interactive]') .action(async options => { if (!findConfigRoot()) { @@ -258,6 +279,11 @@ export class AgentPrimitive extends BasePrimitive { clear(); unmount(); @@ -378,8 +408,10 @@ export class AgentPrimitive extends BasePrimitive { clear(); unmount(); diff --git a/src/cli/primitives/EvaluatorPrimitive.ts b/src/cli/primitives/EvaluatorPrimitive.ts index ffd2a4404..73aaf8073 100644 --- a/src/cli/primitives/EvaluatorPrimitive.ts +++ b/src/cli/primitives/EvaluatorPrimitive.ts @@ -4,6 +4,7 @@ import { EvaluationLevelSchema, EvaluatorSchema } from '../../schema'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; import { renderCodeBasedEvaluatorTemplate } from '../templates/EvaluatorRenderer'; +import { requireTTY } from '../tui/guards/tty'; import { LEVEL_PLACEHOLDERS, RATING_SCALE_PRESETS, @@ -316,6 +317,7 @@ export class EvaluatorPrimitive extends BasePrimitive { clear(); unmount(); diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index 518779530..427aa1533 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -12,6 +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 { requireTTY } from '../tui/guards/tty'; import type { AddGatewayConfig } from '../tui/screens/mcp/types'; import { BasePrimitive } from './BasePrimitive'; import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from './auth-utils'; @@ -162,7 +163,7 @@ export class GatewayPrimitive extends BasePrimitive', 'Gateway name [non-interactive]') .option('--description ', 'Gateway description [non-interactive]') .option('--runtimes ', 'Comma-separated runtime names to expose through this gateway [non-interactive]') - .option('--authorizer-type ', 'Authorizer type: NONE or CUSTOM_JWT [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]') .option('--allowed-audience ', 'Comma-separated allowed audiences (for CUSTOM_JWT) [non-interactive]') .option('--allowed-clients ', 'Comma-separated allowed client IDs (for CUSTOM_JWT) [non-interactive]') @@ -271,6 +272,7 @@ export class GatewayPrimitive extends BasePrimitive { clear(); unmount(); diff --git a/src/cli/primitives/OnlineEvalConfigPrimitive.ts b/src/cli/primitives/OnlineEvalConfigPrimitive.ts index 03687047e..c53bfb88f 100644 --- a/src/cli/primitives/OnlineEvalConfigPrimitive.ts +++ b/src/cli/primitives/OnlineEvalConfigPrimitive.ts @@ -3,6 +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 { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -171,6 +172,7 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { clear(); unmount(); @@ -210,18 +213,6 @@ export class OnlineEvalConfigPrimitive 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 { clear(); unmount(); @@ -416,6 +419,7 @@ export class PolicyPrimitive 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 a7e6d1376..3f69da1ed 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -7,6 +7,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, @@ -18,6 +20,7 @@ export { gatewayTargetPrimitive, configBundlePrimitive, abTestPrimitive, + 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 ea411597d..754b4e182 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -10,6 +10,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'; /** @@ -26,6 +27,7 @@ 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(); /** * All primitives in display order. @@ -42,6 +44,7 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ policyPrimitive, configBundlePrimitive, abTestPrimitive, + 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/templates/BaseRenderer.ts b/src/cli/templates/BaseRenderer.ts index 325aeb392..659722926 100644 --- a/src/cli/templates/BaseRenderer.ts +++ b/src/cli/templates/BaseRenderer.ts @@ -72,7 +72,12 @@ export abstract class BaseRenderer { if (existsSync(containerTemplateDir)) { const exclude = this.config.dockerfile ? new Set(['Dockerfile']) : undefined; - await copyAndRenderDir(containerTemplateDir, projectDir, { ...templateData, entrypoint: 'main' }, { exclude }); + await copyAndRenderDir( + containerTemplateDir, + projectDir, + { ...templateData, entrypoint: 'main', enableOtel: this.config.enableOtel ?? true }, + { exclude } + ); } } } diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index 094af9782..185f7b084 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -64,8 +64,12 @@ export interface AgentRenderConfig { gatewayProviders: GatewayProviderRenderConfig[]; /** Unique auth types across all gateways (for conditional imports) */ gatewayAuthTypes: string[]; - /** Protocol (HTTP, MCP, A2A). Defaults to HTTP. */ + /** Protocol (HTTP, MCP, A2A, AGUI). Defaults to HTTP. */ protocol?: ProtocolMode; /** Custom Dockerfile name — when set, the template Dockerfile is not scaffolded */ dockerfile?: string; + /** Session storage mount path — when set, file read/write tools are included */ + sessionStorageMountPath?: string; + /** Whether to wrap entrypoint with opentelemetry-instrument. Defaults to true. */ + enableOtel?: boolean; } diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index b5cbe78be..96b5c7f85 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -2,6 +2,7 @@ import { getWorkingDirectory } from '../../lib'; import { createProgram } from '../cli'; 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'; @@ -9,7 +10,6 @@ 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 { DevScreen } from './screens/dev/DevScreen'; import { EvalHubScreen, EvalScreen } from './screens/eval'; import { FetchAccessScreen } from './screens/fetch-access'; import { HelpScreen, HomeScreen } from './screens/home'; @@ -33,7 +33,6 @@ const cwd = getWorkingDirectory(); type Route = | { name: 'home' } | { name: 'help'; initialQuery?: string } - | { name: 'dev' } | { name: 'deploy' } | { name: 'invoke' } | { name: 'create' } @@ -97,7 +96,9 @@ function AppContent() { } if (id === 'dev') { - setRoute({ name: 'dev' }); + setExitAction({ type: 'dev' }); + exit(); + return; } else if (id === 'deploy') { setRoute({ name: 'deploy' }); } else if (id === 'invoke') { @@ -168,10 +169,6 @@ function AppContent() { ); } - if (route.name === 'dev') { - return setRoute({ name: 'help' })} />; - } - if (route.name === 'deploy') { return ( setRoute({ name: 'help' })} - onDev={() => setRoute({ name: 'dev' })} + onDev={() => { + setExitAction({ type: 'dev' }); + exit(); + }} onDeploy={() => setRoute({ name: 'deploy' })} /> ); diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index f4c65bfe5..36504cd62 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -22,6 +22,7 @@ const ICONS = { policy: '▢', 'config-bundle': '⬡', 'ab-test': '⚗', + 'runtime-endpoint': '◉', } as const; interface ResourceGraphProps { @@ -184,16 +185,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}]} + + ); + })} + ); })} @@ -468,17 +487,6 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res {ICONS['config-bundle']} config bundle{' '} {ICONS['ab-test']} ab test - {resourceStatuses && resourceStatuses.length > 0 && ( - - - [Deployed] - live in AWS - {' '} - [Local only] - not yet deployed - - - )} ); diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index 516cd5326..b6eb34dfa 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -262,37 +262,6 @@ describe('ResourceGraph', () => { expect(lastFrame()).toContain('deploy'); }); - it('renders deployment state legend when resourceStatuses provided', () => { - const project = { - ...baseProject, - runtimes: [{ name: 'my-agent' }], - } as unknown as AgentCoreProjectSpec; - - const resourceStatuses: ResourceStatusEntry[] = [ - { resourceType: 'agent', name: 'my-agent', deploymentState: 'deployed' }, - ]; - - const { lastFrame } = render(); - - expect(lastFrame()).toContain('[Deployed]'); - expect(lastFrame()).toContain('live in AWS'); - expect(lastFrame()).toContain('[Local only]'); - expect(lastFrame()).toContain('not yet deployed'); - }); - - it('does not render deployment state legend when no resourceStatuses', () => { - const project = { - ...baseProject, - runtimes: [{ name: 'my-agent' }], - } as unknown as AgentCoreProjectSpec; - - const { lastFrame } = render(); - - // Should have the base legend but not the deployment state legend - expect(lastFrame()).toContain('agent'); - expect(lastFrame()).not.toContain('[Deployed]'); - }); - it('renders removed credentials in Removed Locally section', () => { const resourceStatuses: ResourceStatusEntry[] = [ { diff --git a/src/cli/tui/exit-action.ts b/src/cli/tui/exit-action.ts new file mode 100644 index 000000000..9d73f8667 --- /dev/null +++ b/src/cli/tui/exit-action.ts @@ -0,0 +1,21 @@ +/** + * Simple store for post-exit actions to be executed after TUI exits. + * Used to communicate from screens to the main CLI exit handler + * when a screen needs to hand off to a non-TUI mode (e.g., browser dev). + */ + +export type ExitAction = { type: 'dev' } | null; + +let exitAction: ExitAction = null; + +export function setExitAction(action: ExitAction): void { + exitAction = action; +} + +export function getExitAction(): ExitAction { + return exitAction; +} + +export function clearExitAction(): void { + exitAction = null; +} 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/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index d755d0235..062da752f 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -1,5 +1,6 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; import type { DeployedState } from '../../../schema'; +import { applyTargetRegionToEnv } from '../../aws'; import { AwsCredentialsError, validateAwsCredentials } from '../../aws/account'; import { type CdkToolkitWrapper, type SwitchableIoHost, createSwitchableIoHost } from '../../cdk/toolkit-lib'; import { getErrorMessage, isExpiredTokenError, isNoCredentialsError } from '../../errors'; @@ -137,6 +138,11 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const isRunningRef = useRef(false); // Keep a ref to the wrapper so we can dispose it when starting a new run const wrapperRef = useRef(null); + // Restore function for AWS_REGION / AWS_DEFAULT_REGION overrides, applied + // after target resolution so downstream SDK / CDK toolkit-lib clients use the + // aws-targets.json region rather than whatever the SDK default chain resolves. + // See https://github.com/aws/agentcore-cli/issues/924. + const restoreRegionEnvRef = useRef<(() => void) | null>(null); const updateStep = (index: number, update: Partial) => { setSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...update } : s))); @@ -158,10 +164,18 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } }, []); + // Restore AWS_REGION / AWS_DEFAULT_REGION (no-op when nothing was applied) + const restoreRegionEnv = useCallback(() => { + restoreRegionEnvRef.current?.(); + restoreRegionEnvRef.current = null; + }, []); + const startPreflight = useCallback(async () => { if (isRunningRef.current) return; // Dispose any existing wrapper before starting a new run await disposeWrapper(); + // Restore any previously-applied region env override before re-running + restoreRegionEnv(); resetSteps(); setCdkToolkitWrapper(null); setStackNames([]); @@ -169,7 +183,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { setHasTokenExpiredError(false); // Reset token expired state when retrying setHasCredentialsError(false); // Reset credentials error state when retrying setPhase('running'); - }, [disposeWrapper]); + }, [disposeWrapper, restoreRegionEnv]); const clearTokenExpiredError = useCallback(() => { setHasTokenExpiredError(false); @@ -183,6 +197,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { useEffect(() => { const handleInterrupt = () => { void disposeWrapper(); + restoreRegionEnv(); }; process.on('SIGINT', handleInterrupt); @@ -193,8 +208,19 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { process.off('SIGTERM', handleInterrupt); // Dispose on unmount (user navigated away) void disposeWrapper(); + restoreRegionEnv(); }; - }, [disposeWrapper]); + }, [disposeWrapper, restoreRegionEnv]); + + // Restore region env override when any preflight stage lands in 'error'. + // Individual error branches inside the stage effects only call setPhase('error') + // without cleanup, so this hook is the single place that guarantees restore + // happens on every error path without threading the call into every branch. + useEffect(() => { + if (phase === 'error') { + restoreRegionEnv(); + } + }, [phase, restoreRegionEnv]); const confirmTeardown = useCallback(() => { // Mark teardown as confirmed and restart the preflight flow @@ -206,7 +232,8 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const cancelTeardown = useCallback(() => { setPhase('error'); isRunningRef.current = false; - }, []); + restoreRegionEnv(); + }, [restoreRegionEnv]); const confirmBootstrap = useCallback(() => { setPhase('bootstrapping'); @@ -274,6 +301,15 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { try { preflightContext = await validateProject(); setContext(preflightContext); + // Make aws-targets.json region authoritative for downstream SDK / CDK + // toolkit-lib clients that bypass explicit region options. Restored on + // unmount, teardown rejection, or subsequent preflight start. + // See https://github.com/aws/agentcore-cli/issues/924. + const firstTarget = preflightContext.awsTargets[0]; + if (firstTarget) { + restoreRegionEnv(); + restoreRegionEnvRef.current = applyTargetRegionToEnv(firstTarget.region); + } logger.endStep('success'); updateStep(STEP_VALIDATE, { status: 'success' }); } catch (err) { @@ -493,7 +529,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { return () => { process.off('unhandledRejection', handleUnhandledRejection); }; - }, [phase, logger, switchableIoHost, isInteractive, skipIdentityCheck, teardownConfirmed]); + }, [phase, logger, switchableIoHost, isInteractive, skipIdentityCheck, teardownConfirmed, restoreRegionEnv]); // Handle identity-setup phase (after user provides credentials) useEffect(() => { diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index f7fbb92cc..a580b8477 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -1,4 +1,4 @@ -import { findConfigRoot, readEnvFile } from '../../../lib'; +import { findConfigRoot } from '../../../lib'; import type { AgentCoreProjectSpec, ProtocolMode } from '../../../schema'; import { detectContainerRuntime } from '../../external-requirements'; import { DevLogger } from '../../logging/dev-logger'; @@ -18,12 +18,12 @@ import { getEndpointUrl, invokeA2AStreaming, invokeAgentStreaming, + invokeAguiStreaming, listMcpTools, + loadDevEnv, loadProjectConfig, waitForPort, } from '../../operations/dev'; -import { getGatewayEnvVars } from '../../operations/dev/gateway-env.js'; -import { getMemoryEnvVars } from '../../operations/dev/memory-env.js'; import { formatMcpToolList } from '../../operations/dev/utils'; import { spawn } from 'child_process'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -62,6 +62,7 @@ export function useDevServer(options: { const [envVars, setEnvVars] = useState>({}); const [configLoaded, setConfigLoaded] = useState(false); const [hasUndeployedMemory, setHasUndeployedMemory] = useState(false); + const [logFilePath, setLogFilePath] = useState(undefined); const [targetPort] = useState(options.port); const [actualPort, setActualPort] = useState(targetPort); const actualPortRef = useRef(targetPort); @@ -75,11 +76,16 @@ export function useDevServer(options: { const [a2aAgentCard, setA2aAgentCard] = useState(null); const [a2aStatus, setA2aStatus] = useState(null); + // AGUI state — persistent threadId per dev session for multi-turn conversations + const aguiThreadIdRef = useRef(crypto.randomUUID()); + const serverRef = useRef(null); const loggerRef = useRef(null); const logsRef = useRef([]); const onReadyRef = useRef(options.onReady); - onReadyRef.current = options.onReady; + useEffect(() => { + onReadyRef.current = options.onReady; + }, [options.onReady]); // Track instance ID to ignore callbacks from stale server instances const instanceIdRef = useRef(0); // Track if we're intentionally restarting to ignore exit callbacks @@ -103,20 +109,15 @@ export function useDevServer(options: { const cfg = await loadProjectConfig(options.workingDir); setProject(cfg); - // Load env vars from agentcore/.env + // Load env vars from deployed state + agentcore/.env if (root) { - const vars = await readEnvFile(root); - const gatewayEnvVars = await getGatewayEnvVars(); - const memoryEnvVars = await getMemoryEnvVars(); - // Deployed-state env vars go first, .env.local overrides take precedence - const mergedEnvVars = { ...gatewayEnvVars, ...memoryEnvVars, ...vars }; - setEnvVars(mergedEnvVars); + const devEnv = await loadDevEnv(options.workingDir); + setEnvVars(devEnv.envVars); // Show warning only when some configured memories aren't deployed yet const configuredMemories = cfg?.memories ?? []; if (configuredMemories.length > 0) { - const deployedCount = Object.keys(memoryEnvVars).length; - setHasUndeployedMemory(deployedCount < configuredMemories.length); + setHasUndeployedMemory(devEnv.deployedMemoryCount < configuredMemories.length); } } @@ -148,6 +149,7 @@ export function useDevServer(options: { baseDir: options.workingDir, agentName: config.agentName, }); + setLogFilePath(loggerRef.current.getRelativeLogPath()); // A2A servers always use port 9000, MCP servers use port 8000 (framework defaults, not configurable via env) const isA2A = config.protocol === 'A2A'; @@ -345,12 +347,20 @@ export function useDevServer(options: { onStatus: setA2aStatus, headers: options.headers, }) - : invokeAgentStreaming({ - port: actualPort, - message, - logger: loggerRef.current ?? undefined, - headers: options.headers, - }); + : protocol === 'AGUI' + ? invokeAguiStreaming({ + port: actualPort, + message, + logger: loggerRef.current ?? undefined, + headers: options.headers, + threadId: aguiThreadIdRef.current, + }) + : invokeAgentStreaming({ + port: actualPort, + message, + logger: loggerRef.current ?? undefined, + headers: options.headers, + }); for await (const chunk of streamFn) { responseContent += chunk; @@ -512,6 +522,7 @@ export function useDevServer(options: { const clearConversation = () => { setConversation([]); setStreamingResponse(null); + aguiThreadIdRef.current = crypto.randomUUID(); }; const showMcpHint = () => { @@ -538,7 +549,7 @@ export function useDevServer(options: { clearConversation, restart, stop, - logFilePath: loggerRef.current?.getRelativeLogPath(), + logFilePath, hasUndeployedMemory, hasVpc: project?.runtimes.find(a => a.name === config?.agentName)?.networkMode === 'VPC', protocol, diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index ba95f3dc3..7682479d3 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 { abTestPrimitive, agentPrimitive, @@ -16,6 +17,7 @@ import { onlineEvalConfigPrimitive, policyEnginePrimitive, policyPrimitive, + runtimeEndpointPrimitive, } from '../../primitives/registry'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -25,6 +27,7 @@ export type { RemovableCredential as RemovableIdentity, RemovableGatewayTarget, RemovablePolicyResource, + RemovableRuntimeEndpoint, }; // ============================================================================ @@ -167,6 +170,13 @@ export function useRemoveABTest() { ); } +export function useRemovableRuntimeEndpoints() { + const { items: endpoints, ...rest } = useRemovableResources(() => + runtimeEndpointPrimitive.getRemovable() + ); + return { endpoints, ...rest }; +} + // ============================================================================ // Preview Hook // ============================================================================ @@ -248,6 +258,11 @@ export function useRemovalPreview() { [loadPreview] ); + const loadRuntimeEndpointPreview = useCallback( + (name: string) => loadPreview(n => runtimeEndpointPrimitive.previewRemove(n), name), + [loadPreview] + ); + const reset = useCallback(() => { setState({ isLoading: false, preview: null, error: null }); }, []); @@ -265,6 +280,7 @@ export function useRemovalPreview() { loadPolicyPreview, loadConfigBundlePreview, loadABTestPreview, + loadRuntimeEndpointPreview, reset, }; } @@ -359,3 +375,11 @@ export function useRemoveConfigBundle() { name => name ); } + +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 c5be8c2ed..422589bc3 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -15,6 +15,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'; @@ -34,6 +35,7 @@ type FlowState = | { name: 'policy-wizard' } | { name: 'config-bundle-wizard' } | { name: 'ab-test-wizard' } + | { name: 'runtime-endpoint-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -161,12 +163,39 @@ interface AddFlowProps { onDev?: () => void; /** Called when user selects deploy from success screen */ onDeploy?: () => void; + /** Skip the selection screen and go directly to a specific resource wizard */ + initialResource?: AddResourceType; +} + +function getInitialFlowState(resource?: AddResourceType): FlowState { + switch (resource) { + case 'agent': + return { name: 'agent-wizard' }; + case 'gateway': + return { name: 'gateway-wizard' }; + case 'gateway-target': + return { name: 'tool-wizard' }; + case 'memory': + return { name: 'memory-wizard' }; + case 'credential': + return { name: 'identity-wizard' }; + case 'evaluator': + return { name: 'evaluator-wizard' }; + case 'online-eval': + return { name: 'online-eval-wizard' }; + case 'policy': + return { name: 'policy-wizard' }; + case 'runtime-endpoint': + return { name: 'runtime-endpoint-wizard' }; + default: + return { name: 'select' }; + } } export function AddFlow(props: AddFlowProps) { const { addAgent, reset: resetAgent } = useAddAgent(); const { agents, refresh: refreshAgents } = useAvailableAgents(); - const [flow, setFlow] = useState({ name: 'select' }); + const [flow, setFlow] = useState(() => getInitialFlowState(props.initialResource)); // In non-interactive mode, exit after success (but not while loading) useEffect(() => { @@ -211,6 +240,9 @@ export function AddFlow(props: AddFlowProps) { case 'ab-test': setFlow({ name: 'ab-test-wizard' }); break; + case 'runtime-endpoint': + setFlow({ name: 'runtime-endpoint-wizard' }); + break; } }, []); @@ -470,6 +502,18 @@ export function AddFlow(props: AddFlowProps) { ); } + if (flow.name === 'runtime-endpoint-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( >(new Set()); const [byoAuthorizerType, setByoAuthorizerType] = useState('AWS_IAM'); @@ -305,6 +310,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg }), idleRuntimeSessionTimeout: generateWizard.config.idleRuntimeSessionTimeout, maxLifetime: generateWizard.config.maxLifetime, + sessionStorageMountPath: generateWizard.config.sessionStorageMountPath, pythonVersion: DEFAULT_PYTHON_VERSION, memory: generateWizard.config.memory, }; @@ -426,6 +432,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...(byoAuthorizerType === 'CUSTOM_JWT' && byoJwtConfig && { jwtConfig: byoJwtConfig }), ...(byoConfig.idleTimeout && { idleRuntimeSessionTimeout: Number(byoConfig.idleTimeout) }), ...(byoConfig.maxLifetime && { maxLifetime: Number(byoConfig.maxLifetime) }), + ...(byoConfig.sessionStorageMountPath && { sessionStorageMountPath: byoConfig.sessionStorageMountPath }), pythonVersion: DEFAULT_PYTHON_VERSION, memory: 'none', }; @@ -486,6 +493,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg requestHeaderAllowlist: '', idleTimeout: '', maxLifetime: '', + sessionStorageMountPath: '', })); setByoAuthorizerType('AWS_IAM'); setByoJwtConfig(undefined); @@ -503,6 +511,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg setByoStep('authorizerType'); } else if (selected.has('lifecycle')) { setByoStep('idleTimeout'); + } else if (selected.has('filesystem')) { + setByoStep('sessionStorageMountPath'); } else { setByoStep('confirm'); } @@ -810,7 +820,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg byoStep === 'securityGroups' || byoStep === 'requestHeaderAllowlist' || byoStep === 'idleTimeout' || - byoStep === 'maxLifetime' + byoStep === 'maxLifetime' || + byoStep === 'sessionStorageMountPath' ) { return HELP_TEXT.TEXT_INPUT; } @@ -1242,7 +1253,25 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg }} onSubmit={value => { setByoConfig(c => ({ ...c, maxLifetime: value })); - setByoStep('confirm'); + goToNextByoStep('maxLifetime'); + }} + onCancel={handleByoBack} + /> + )} + + {byoStep === 'sessionStorageMountPath' && ( + { + if (!value) return true; + if (!value.startsWith('/')) return 'Must be an absolute path starting with /'; + return true; + }} + onSubmit={value => { + setByoConfig(c => ({ ...c, sessionStorageMountPath: value })); + goToNextByoStep('sessionStorageMountPath'); }} onCancel={handleByoBack} /> @@ -1316,6 +1345,9 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg : []), ...(byoConfig.idleTimeout ? [{ label: 'Idle Timeout', value: `${byoConfig.idleTimeout}s` }] : []), ...(byoConfig.maxLifetime ? [{ label: 'Max Lifetime', value: `${byoConfig.maxLifetime}s` }] : []), + ...(byoConfig.sessionStorageMountPath + ? [{ label: 'Session Storage', value: byoConfig.sessionStorageMountPath }] + : []), ]} /> )} diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index d8dc6c899..bc1553352 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -55,6 +55,7 @@ export type AddAgentStep = | 'jwtConfig' | 'idleTimeout' | 'maxLifetime' + | 'sessionStorageMountPath' | 'memory' | 'region' | 'bedrockAgent' @@ -72,7 +73,7 @@ export interface AddAgentConfig { buildType: BuildType; /** Path to custom Dockerfile (copied into code directory at setup) or filename already in code directory. */ dockerfile?: string; - /** Protocol (HTTP, MCP, A2A). Defaults to HTTP. */ + /** Protocol (HTTP, MCP, A2A, AGUI). Defaults to HTTP. */ protocol: ProtocolMode; framework: SDKFramework; modelProvider: ModelProvider; @@ -94,6 +95,8 @@ export interface AddAgentConfig { idleRuntimeSessionTimeout?: number; /** Max instance lifetime in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ maxLifetime?: number; + /** Mount path for session filesystem storage (e.g. /mnt/session-storage) */ + sessionStorageMountPath?: string; /** Python version (only for Python agents) */ pythonVersion: PythonRuntime; /** Memory option (create path only) */ @@ -126,6 +129,7 @@ export const ADD_AGENT_STEP_LABELS: Record = { jwtConfig: 'JWT Config', idleTimeout: 'Idle Timeout', maxLifetime: 'Max Lifetime', + sessionStorageMountPath: 'Session Storage', memory: 'Memory', region: 'Region', bedrockAgent: 'Agent', @@ -189,5 +193,5 @@ export const NETWORK_MODE_OPTIONS = [ { id: 'VPC', title: 'VPC', description: 'Attach to your VPC' }, ] as const; -export const DEFAULT_PYTHON_VERSION: PythonRuntime = 'PYTHON_3_13'; +export { DEFAULT_PYTHON_VERSION } from '../../../../schema'; export const DEFAULT_ENTRYPOINT = 'main.py'; diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index 63407d2f1..3160e8712 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -90,6 +90,9 @@ export function mapByoConfigToAgent(config: AddAgentConfig): AgentEnvSpec { }, } : {}), + ...(config.sessionStorageMountPath && { + filesystemConfigurations: [{ sessionStorage: { mountPath: config.sessionStorageMountPath } }], + }), }; } @@ -114,6 +117,7 @@ function mapAddAgentConfigToGenerateConfig(config: AddAgentConfig): GenerateConf jwtConfig: config.jwtConfig, idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, maxLifetime: config.maxLifetime, + sessionStorageMountPath: config.sessionStorageMountPath, }; } @@ -289,6 +293,7 @@ async function handleImportPath( jwtConfig: config.jwtConfig, idleTimeout: config.idleRuntimeSessionTimeout, maxLifetime: config.maxLifetime, + sessionStorageMountPath: config.sessionStorageMountPath, }); if (!result.success) { diff --git a/src/cli/tui/screens/create/CreateScreen.tsx b/src/cli/tui/screens/create/CreateScreen.tsx index 5b56a0ac1..801dfc8d8 100644 --- a/src/cli/tui/screens/create/CreateScreen.tsx +++ b/src/cli/tui/screens/create/CreateScreen.tsx @@ -237,75 +237,12 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS isActive: flow.phase === 'create-prompt', }); - // Checking phase: brief loading state + // Checking phase: instant async check — render nothing to avoid a flash before the real UI if (flow.phase === 'checking') { - 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/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 91610b47a..04c003ad0 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -275,6 +275,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { jwtConfig: addAgentConfig.jwtConfig, idleRuntimeSessionTimeout: addAgentConfig.idleRuntimeSessionTimeout, maxLifetime: addAgentConfig.maxLifetime, + sessionStorageMountPath: addAgentConfig.sessionStorageMountPath, }; logger.logSubStep(`Framework: ${generateConfig.sdk}`); @@ -350,6 +351,9 @@ export function useCreateFlow(cwd: string): CreateFlowState { configBaseDir, authorizerType: addAgentConfig.authorizerType, jwtConfig: addAgentConfig.jwtConfig, + idleTimeout: addAgentConfig.idleRuntimeSessionTimeout, + maxLifetime: addAgentConfig.maxLifetime, + sessionStorageMountPath: addAgentConfig.sessionStorageMountPath, }); if (!importResult.success) { throw new Error(importResult.error ?? 'Import failed'); diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 405c356a3..319f970ec 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -230,13 +230,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 @@ -306,7 +306,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'; @@ -318,114 +322,125 @@ 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 && postDeployWarnings.length > 0 && ( - - - Post-deploy warnings: - - {postDeployWarnings.map((w, i) => ( - - {' '} - {w} - - ))} - - )} - - {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 && postDeployWarnings.length > 0 && ( + + + Post-deploy warnings: + + {postDeployWarnings.map((w, i) => ( + + {' '} + {w} + + ))} + + )} + + {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 ed72eae3e..b2ebf4bda 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -133,6 +133,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' }); @@ -157,6 +161,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); @@ -544,6 +549,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') { @@ -562,6 +569,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState switchableIoHost?.setOnRawMessage(null); isDiffRunningRef.current = false; setIsDiffLoading(false); + logger.endStep('success'); + setPreDeployDiffStep(prev => ({ ...prev, status: 'success' })); } } @@ -786,8 +795,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/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index f636e20e6..0040aaf30 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -623,7 +623,7 @@ export function DevScreen(props: DevScreenProps) { ? undefined : isMcp ? 'tool_name {"arg": "value"}' - : protocol === 'A2A' + : protocol === 'A2A' || protocol === 'AGUI' ? 'Send a message...' : undefined } diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index b4f25f660..4b61e4689 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -1,5 +1,11 @@ import type { ModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; -import { DEFAULT_MODEL_IDS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN, ProjectNameSchema } from '../../../../schema'; +import { + DEFAULT_MODEL_IDS, + LIFECYCLE_TIMEOUT_MAX, + LIFECYCLE_TIMEOUT_MIN, + ProjectNameSchema, + SessionStorageSchema, +} from '../../../../schema'; import { parseAndNormalizeHeaders, validateHeaderAllowlist } from '../../../commands/shared/header-utils'; import { validateSecurityGroupIds, validateSubnetIds } from '../../../commands/shared/vpc-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; @@ -113,6 +119,7 @@ export function GenerateWizardUI({ const isJwtConfigStep = wizard.step === 'jwtConfig'; const isIdleTimeoutStep = wizard.step === 'idleTimeout'; const isMaxLifetimeStep = wizard.step === 'maxLifetime'; + const isSessionStorageMountPathStep = wizard.step === 'sessionStorageMountPath'; const isConfirmStep = wizard.step === 'confirm'; // Advanced multi-select items — filter out dockerfile when not a Container build @@ -380,6 +387,23 @@ export function GenerateWizardUI({ /> )} + {isSessionStorageMountPathStep && ( + { + if (value) { + wizard.setSessionStorageMountPath(value); + } else { + wizard.skipSessionStorageMountPath(); + } + }} + onCancel={onBack} + /> + )} + {isConfirmStep && } ); @@ -398,7 +422,8 @@ export function getWizardHelpText(step: GenerateStep): string { step === 'securityGroups' || step === 'requestHeaderAllowlist' || step === 'idleTimeout' || - step === 'maxLifetime' + step === 'maxLifetime' || + step === 'sessionStorageMountPath' ) return 'Enter submit · Esc cancel'; if (step === 'apiKey') return 'Enter submit · Tab show/hide · Esc back'; @@ -546,6 +571,12 @@ function ConfirmView({ config, credentialProjectName }: { config: GenerateConfig {config.maxLifetime}s )} + {config.sessionStorageMountPath && ( + + Session Storage: + {config.sessionStorageMountPath} + + )} ); diff --git a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx index d303be1d6..6d71a5acc 100644 --- a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx +++ b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx @@ -385,6 +385,133 @@ describe('useGenerateWizard — advanced config gate', () => { }); }); + describe('filesystem advanced setting', () => { + function walkToAdvanced(ref: React.RefObject) { + act(() => { + ref.current!.wizard.setProjectName('Test'); + ref.current!.wizard.setLanguage('Python'); + ref.current!.wizard.setBuildType('CodeZip'); + ref.current!.wizard.setProtocol('HTTP'); + ref.current!.wizard.setSdk('Strands'); + ref.current!.wizard.setModelProvider('Bedrock'); + ref.current!.wizard.setMemory('none'); + }); + } + + it('filesystem step is included in steps when filesystem is selected', () => { + const { ref } = setup(); + walkToAdvanced(ref); + + act(() => ref.current!.wizard.setAdvanced(['filesystem'])); + + expect(ref.current!.wizard.steps).toContain('sessionStorageMountPath'); + }); + + it('setAdvanced with only filesystem navigates to sessionStorageMountPath step', () => { + vi.useFakeTimers(); + const { ref, lastFrame } = setup(); + walkToAdvanced(ref); + + act(() => ref.current!.wizard.setAdvanced(['filesystem'])); + act(() => { + vi.runAllTimers(); + }); + + expect(lastFrame()).toContain('step:sessionStorageMountPath'); + vi.useRealTimers(); + }); + + it('setSessionStorageMountPath rejects invalid path and sets error without advancing', () => { + vi.useFakeTimers(); + const { ref } = setup(); + walkToAdvanced(ref); + + act(() => ref.current!.wizard.setAdvanced(['filesystem'])); + act(() => { + vi.runAllTimers(); + }); + + let result: boolean | undefined; + act(() => { + result = ref.current!.wizard.setSessionStorageMountPath('/bad/path/too/deep'); + }); + act(() => { + vi.runAllTimers(); + }); + + expect(result).toBe(false); + expect(ref.current!.wizard.error).toBeTruthy(); + expect(ref.current!.wizard.step).toBe('sessionStorageMountPath'); + vi.useRealTimers(); + }); + + it('setSessionStorageMountPath accepts valid path, clears error, and advances to confirm', () => { + vi.useFakeTimers(); + const { ref, lastFrame } = setup(); + walkToAdvanced(ref); + + act(() => ref.current!.wizard.setAdvanced(['filesystem'])); + act(() => { + vi.runAllTimers(); + }); + + let result: boolean | undefined; + act(() => { + result = ref.current!.wizard.setSessionStorageMountPath('/mnt/data'); + }); + act(() => { + vi.runAllTimers(); + }); + + expect(result).toBe(true); + expect(ref.current!.wizard.error).toBeNull(); + expect(lastFrame()).toContain('step:confirm'); + expect(ref.current!.wizard.config.sessionStorageMountPath).toBe('/mnt/data'); + vi.useRealTimers(); + }); + + it('lifecycle + filesystem injects both sub-step groups', () => { + const { ref } = setup(); + walkToAdvanced(ref); + + act(() => ref.current!.wizard.setAdvanced(['lifecycle', 'filesystem'])); + + const steps = ref.current!.wizard.steps; + const advIdx = steps.indexOf('advanced'); + expect(steps.slice(advIdx)).toEqual([ + 'advanced', + 'idleTimeout', + 'maxLifetime', + 'sessionStorageMountPath', + 'confirm', + ]); + }); + + it('deselecting all advanced clears sessionStorageMountPath config', () => { + vi.useFakeTimers(); + const { ref } = setup(); + walkToAdvanced(ref); + + act(() => ref.current!.wizard.setAdvanced(['filesystem'])); + act(() => { + vi.runAllTimers(); + }); + act(() => { + ref.current!.wizard.setSessionStorageMountPath('/mnt/data'); + }); + act(() => { + vi.runAllTimers(); + }); + expect(ref.current!.wizard.config.sessionStorageMountPath).toBe('/mnt/data'); + + act(() => ref.current!.wizard.setAdvanced([])); + + expect(ref.current!.wizard.config.sessionStorageMountPath).toBeUndefined(); + expect(ref.current!.wizard.step).toBe('confirm'); + vi.useRealTimers(); + }); + }); + describe('reset clears advancedSelected', () => { it('reset returns advancedSelected to false', () => { const { ref, lastFrame } = setup(); diff --git a/src/cli/tui/screens/generate/defaults.ts b/src/cli/tui/screens/generate/defaults.ts index ad50c5913..4524d2c9e 100644 --- a/src/cli/tui/screens/generate/defaults.ts +++ b/src/cli/tui/screens/generate/defaults.ts @@ -1,12 +1,11 @@ -import type { NetworkMode, PythonRuntime } from '../../../../schema'; +import type { NetworkMode } from '../../../../schema'; + +export { DEFAULT_PYTHON_VERSION } from '../../../../schema'; /** * Default configuration values for create command */ -/** Default Python runtime version for new agents */ -export const DEFAULT_PYTHON_VERSION: PythonRuntime = 'PYTHON_3_13'; - /** Default network mode for agent runtimes */ export const DEFAULT_NETWORK_MODE: NetworkMode = 'PUBLIC'; diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index eb1916399..697b318bd 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -29,6 +29,7 @@ export type GenerateStep = | 'jwtConfig' | 'idleTimeout' | 'maxLifetime' + | 'sessionStorageMountPath' | 'confirm'; export type MemoryOption = 'none' | 'shortTerm' | 'longAndShortTerm'; @@ -61,6 +62,8 @@ export interface GenerateConfig { idleRuntimeSessionTimeout?: number; /** Max instance lifetime in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ maxLifetime?: number; + /** Mount path for session filesystem storage (e.g. /mnt/session-storage) */ + sessionStorageMountPath?: string; } /** Base steps - apiKey, memory, subnets, securityGroups are conditionally added based on selections */ @@ -95,6 +98,7 @@ export const STEP_LABELS: Record = { jwtConfig: 'JWT Config', idleTimeout: 'Idle Timeout', maxLifetime: 'Max Lifetime', + sessionStorageMountPath: 'Session Storage', confirm: 'Confirm', }; @@ -112,6 +116,7 @@ export const PROTOCOL_OPTIONS = [ { id: 'HTTP', title: 'HTTP', description: 'Standard HTTP agent (default)' }, { id: 'MCP', title: 'MCP', description: 'Model Context Protocol tool server' }, { id: 'A2A', title: 'A2A', description: 'Agent-to-Agent protocol' }, + { id: 'AGUI', title: 'AG-UI', description: 'Stream rich agent events to frontends' }, ] as const; export const SDK_OPTIONS = [ @@ -153,7 +158,7 @@ export const NETWORK_MODE_OPTIONS = [ { id: 'VPC', title: 'VPC', description: 'Attach to your VPC' }, ] as const; -export type AdvancedSettingId = 'dockerfile' | 'network' | 'headers' | 'auth' | 'lifecycle'; +export type AdvancedSettingId = 'dockerfile' | 'network' | 'headers' | 'auth' | 'lifecycle' | 'filesystem'; export const ADVANCED_SETTING_OPTIONS = [ { id: 'dockerfile', title: 'Custom Dockerfile', description: 'Specify a custom Dockerfile path' }, @@ -161,6 +166,7 @@ export const ADVANCED_SETTING_OPTIONS = [ { id: 'headers', title: 'Request header allowlist', description: 'Allow custom headers through to your agent' }, { 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' }, ] 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 982457aa2..16e016ad6 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -1,5 +1,5 @@ import type { NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; -import { ProjectNameSchema } from '../../../../schema'; +import { ProjectNameSchema, SessionStorageSchema } from '../../../../schema'; import type { JwtConfigOptions } from '../../../primitives/auth-utils'; import type { AdvancedSettingId, BuildType, GenerateConfig, GenerateStep, MemoryOption, ProtocolMode } from './types'; import { BASE_GENERATE_STEPS, getModelProviderOptionsForSdk } from './types'; @@ -85,6 +85,10 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { if (advancedSettings.has('lifecycle')) { subSteps.push('idleTimeout', 'maxLifetime'); } + // Filesystem + if (advancedSettings.has('filesystem')) { + subSteps.push('sessionStorageMountPath'); + } filtered = [...filtered.slice(0, afterAdvanced), ...subSteps, ...filtered.slice(afterAdvanced)]; } // Add jwtConfig step after authorizerType when CUSTOM_JWT is selected @@ -229,6 +233,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { jwtConfig: undefined, idleRuntimeSessionTimeout: undefined, maxLifetime: undefined, + sessionStorageMountPath: undefined, })); setStep('confirm'); } else { @@ -246,6 +251,8 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setStep('authorizerType'); } else if (selected.has('lifecycle')) { setStep('idleTimeout'); + } else if (selected.has('filesystem')) { + setStep('sessionStorageMountPath'); } else { setStep('confirm'); } @@ -325,14 +332,38 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setStep('maxLifetime'); }, []); - const setMaxLifetime = useCallback((value: number | undefined) => { - setConfig(c => ({ ...c, maxLifetime: value })); - setStep('confirm'); - }, []); + const setMaxLifetime = useCallback( + (value: number | undefined) => { + setConfig(c => ({ ...c, maxLifetime: value })); + setTimeout(() => goToNextStep('maxLifetime'), 0); + }, + [goToNextStep] + ); const skipMaxLifetime = useCallback(() => { - setStep('confirm'); - }, []); + setTimeout(() => goToNextStep('maxLifetime'), 0); + }, [goToNextStep]); + + const setSessionStorageMountPath = useCallback( + (value: string | undefined) => { + if (value) { + const result = SessionStorageSchema.shape.mountPath.safeParse(value); + if (!result.success) { + setError(result.error.issues[0]?.message ?? 'Invalid mount path'); + return false; + } + } + setError(null); + setConfig(c => ({ ...c, sessionStorageMountPath: value })); + setTimeout(() => goToNextStep('sessionStorageMountPath'), 0); + return true; + }, + [goToNextStep] + ); + + const skipSessionStorageMountPath = useCallback(() => { + setTimeout(() => goToNextStep('sessionStorageMountPath'), 0); + }, [goToNextStep]); const goBack = useCallback(() => { setError(null); @@ -390,6 +421,8 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { skipIdleTimeout, setMaxLifetime, skipMaxLifetime, + setSessionStorageMountPath, + skipSessionStorageMountPath, goBack, reset, initWithName, 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 850fde519..71b8d56e8 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/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index 188f9a694..e56957508 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -4,11 +4,11 @@ import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; import { HELP_TEXT } from '../../constants'; -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; +const ARN_PATTERN = /^arn:[^:]+:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; function validateArn(value: string): true | string { if (!ARN_PATTERN.test(value)) { - return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; + return 'Invalid ARN format. Expected: arn::bedrock-agentcore:::/'; } return true; } @@ -29,7 +29,7 @@ const RESOURCE_TYPE_LABELS: Record = { export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { const title = RESOURCE_TYPE_LABELS[resourceType] ?? `Import ${resourceType}`; const arnResourceType = resourceType === 'online-eval' ? 'online-evaluation-config' : resourceType; - const placeholder = `arn:aws:bedrock-agentcore:::${arnResourceType}/`; + const placeholder = `arn::bedrock-agentcore:::${arnResourceType}/`; return ( 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/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index f02e40e4e..d5e70330c 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -1,5 +1,6 @@ import { buildTraceConsoleUrl } from '../../../operations/traces'; import { GradientText, LogLink, Panel, Screen, SelectList, TextInput } from '../../components'; +import { setExitMessage } from '../../exit-message'; import { useInvokeFlow } from './useInvokeFlow'; import { Box, Text, useInput, useStdout } from 'ink'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -28,7 +29,13 @@ interface ColoredLine { * Each line carries its own color so that word-wrapping preserves it. */ function formatConversation( - messages: { role: 'user' | 'assistant'; content: string; isHint?: boolean; isExec?: boolean }[] + messages: { + role: 'user' | 'assistant'; + content: string; + isHint?: boolean; + isExec?: boolean; + parts?: import('./useInvokeFlow').MessagePart[]; + }[] ): ColoredLine[] { const lines: ColoredLine[] = []; @@ -42,6 +49,22 @@ function formatConversation( lines.push({ text: `> ${msg.content}`, color: 'blue' }); } else if (msg.isExec) { lines.push({ text: msg.content }); + } else if (msg.parts && msg.parts.length > 0) { + // Rich AGUI rendering: render each part with distinct visual treatment + for (const part of msg.parts) { + if (part.kind === 'text') { + lines.push({ text: part.text, color: 'green' }); + } else if (part.kind === 'tool_call') { + lines.push({ text: ` [tool] ${part.name}(${part.args})`, color: 'gray' }); + if (part.result) { + lines.push({ text: ` [result] ${part.result}`, color: 'gray' }); + } + } else if (part.kind === 'reasoning') { + lines.push({ text: ` [thinking] ${part.text}`, color: 'gray' }); + } else if (part.kind === 'error') { + lines.push({ text: `Error: ${part.message}${part.code ? ` (${part.code})` : ''}`, color: 'red' }); + } + } } else { lines.push({ text: msg.content, color: 'green' }); } @@ -145,6 +168,14 @@ export function InvokeScreen({ const justCancelledRef = useRef(false); const mcpFetchTriggeredRef = useRef(false); + useEffect(() => { + if (sessionId && messages.length > 0) { + const cyan = '\x1b[36m'; + const reset = '\x1b[0m'; + setExitMessage(`To resume this session, run: ${cyan}agentcore invoke --session-id ${sessionId}${reset}`); + } + }, [sessionId, messages.length]); + // Compute auth type early so hooks can reference it const currentAgent = config?.runtimes[selectedAgent]; const isCustomJwt = currentAgent?.authorizerType === 'CUSTOM_JWT'; @@ -398,7 +429,7 @@ export function InvokeScreen({ {mode !== 'select-agent' && ( Session: - {sessionId?.slice(0, 8) ?? 'none'} + {sessionId ?? 'none'} )} {mode !== 'select-agent' && ( @@ -527,7 +558,7 @@ export function InvokeScreen({ ? undefined : isMcp ? 'tool_name {"arg": "value"}' - : agentProtocol === 'A2A' + : agentProtocol === 'A2A' || agentProtocol === 'AGUI' ? 'Send a message...' : undefined } diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 2aeb083c1..2cb49b071 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -9,11 +9,14 @@ import type { AgentCoreProjectSpec as _AgentCoreProjectSpec, } from '../../../../schema'; import { + AguiEventType, DEFAULT_RUNTIME_USER_ID, type McpToolDef, + buildAguiRunInput, executeBashCommand, invokeA2ARuntime, invokeAgentRuntimeStreaming, + invokeAguiRuntime, mcpCallTool, mcpListTools, } from '../../../aws'; @@ -24,6 +27,13 @@ import { canFetchRuntimeToken, fetchRuntimeToken } from '../../../operations/fet import { generateSessionId } from '../../../operations/session'; import { useCallback, useEffect, useRef, useState } from 'react'; +/** Structured message part for rich AGUI event rendering */ +export type MessagePart = + | { kind: 'text'; text: string } + | { kind: 'tool_call'; id: string; name: string; args: string; result?: string } + | { kind: 'reasoning'; text: string } + | { kind: 'error'; message: string; code?: string }; + export interface InvokeConfig { runtimes: { name: string; @@ -52,7 +62,7 @@ export interface InvokeFlowState { phase: 'loading' | 'ready' | 'invoking' | 'error'; config: InvokeConfig | null; selectedAgent: number; - messages: { role: 'user' | 'assistant'; content: string; isHint?: boolean }[]; + messages: { role: 'user' | 'assistant'; content: string; isHint?: boolean; parts?: MessagePart[] }[]; error: string | null; logFilePath: string | null; sessionId: string | null; @@ -79,7 +89,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const [config, setConfig] = useState(null); const [selectedAgent, setSelectedAgent] = useState(0); const [messages, setMessages] = useState< - { role: 'user' | 'assistant'; content: string; isHint?: boolean; isExec?: boolean }[] + { role: 'user' | 'assistant'; content: string; isHint?: boolean; isExec?: boolean; parts?: MessagePart[] }[] >([]); const [error, setError] = useState(null); const [logFilePath, setLogFilePath] = useState(null); @@ -313,6 +323,122 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState return; } + // AGUI: structured event streaming with rich rendering + if (agent.protocol === 'AGUI') { + const aguiInput = buildAguiRunInput(prompt, sessionId ?? undefined); + + setMessages(prev => [...prev, { role: 'user', content: prompt }, { role: 'assistant', content: '' }]); + setPhase('invoking'); + streamingContentRef.current = ''; + + logger.logPrompt(prompt, sessionId ?? undefined, userId); + + try { + const aguiResult = await invokeAguiRuntime( + { + region: config.target.region, + runtimeArn: agent.state.runtimeArn, + userId, + logger, + headers, + bearerToken: bearerToken || undefined, + }, + aguiInput + ); + + if (aguiResult.sessionId) { + setSessionId(aguiResult.sessionId); + logger.updateSessionId(aguiResult.sessionId); + } + + const parts: MessagePart[] = []; + let currentToolCall: { id: string; name: string; args: string } | null = null; + + for await (const event of aguiResult.stream) { + if (event.type === AguiEventType.TEXT_MESSAGE_CONTENT) { + const delta = (event as { delta: string }).delta; + streamingContentRef.current += delta; + // Accumulate text part — replace instead of mutate for React state safety + const lastPart = parts[parts.length - 1]; + if (lastPart?.kind === 'text') { + parts[parts.length - 1] = { ...lastPart, text: lastPart.text + delta }; + } else { + parts.push({ kind: 'text', text: delta }); + } + } else if (event.type === AguiEventType.TOOL_CALL_START) { + const tc = event as { toolCallId: string; toolCallName: string }; + currentToolCall = { id: tc.toolCallId, name: tc.toolCallName, args: '' }; + } else if (event.type === AguiEventType.TOOL_CALL_ARGS && currentToolCall) { + currentToolCall.args += (event as { delta: string }).delta; + } else if (event.type === AguiEventType.TOOL_CALL_END && currentToolCall) { + parts.push({ + kind: 'tool_call', + id: currentToolCall.id, + name: currentToolCall.name, + args: currentToolCall.args, + }); + currentToolCall = null; + } else if (event.type === AguiEventType.TOOL_CALL_RESULT) { + const result = event as { toolCallId: string; content: unknown }; + const idx = parts.findIndex(p => p.kind === 'tool_call' && p.id === result.toolCallId); + if (idx >= 0) { + const toolPart = parts[idx]!; + if (toolPart.kind === 'tool_call') { + parts[idx] = { + ...toolPart, + result: typeof result.content === 'string' ? result.content : JSON.stringify(result.content), + }; + } + } + } else if (event.type === AguiEventType.REASONING_MESSAGE_CONTENT) { + const delta = (event as { delta: string }).delta; + const lastPart = parts[parts.length - 1]; + if (lastPart?.kind === 'reasoning') { + parts[parts.length - 1] = { ...lastPart, text: lastPart.text + delta }; + } else { + parts.push({ kind: 'reasoning', text: delta }); + } + } else if (event.type === AguiEventType.RUN_ERROR) { + const err = event as { message: string; code?: string }; + parts.push({ kind: 'error', message: err.message, code: err.code }); + streamingContentRef.current += `\nError: ${err.message}`; + } + + const currentContent = streamingContentRef.current; + const currentParts = [...parts]; + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { + ...updated[lastIdx], + role: 'assistant', + content: currentContent, + parts: currentParts, + }; + } + return updated; + }); + } + + logger.logResponse(streamingContentRef.current); + setPhase('ready'); + } catch (err) { + const errMsg = getErrorMessage(err); + logger.logError(err, 'AGUI invoke failed'); + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx]?.role === 'assistant') { + updated[lastIdx] = { role: 'assistant', content: `Error: ${errMsg}` }; + } + return updated; + }); + setPhase('ready'); + } + return; + } + // HTTP / A2A: streaming invoke const isA2A = agent.protocol === 'A2A'; setMessages(prev => [...prev, { role: 'user', content: prompt }, { role: 'assistant', content: '' }]); @@ -324,7 +450,14 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState try { const result = isA2A ? await invokeA2ARuntime( - { region: config.target.region, runtimeArn: agent.state.runtimeArn, userId, logger, headers }, + { + region: config.target.region, + runtimeArn: agent.state.runtimeArn, + userId, + sessionId: sessionId ?? undefined, + logger, + headers, + }, prompt ) : await invokeAgentRuntimeStreaming({ diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index f7073749e..557e560a0 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -75,12 +75,14 @@ export function AddGatewayScreen({ // Reset when re-entering the step (e.g., after navigating back) const [policyEngineSubStep, setPolicyEngineSubStep] = useState(0); const [selectedEngineName, setSelectedEngineName] = useState(''); - React.useEffect(() => { + const [prevWizardStep, setPrevWizardStep] = useState(wizard.step); + if (prevWizardStep !== wizard.step) { + setPrevWizardStep(wizard.step); if (wizard.step === 'policy-engine') { setPolicyEngineSubStep(0); setSelectedEngineName(''); } - }, [wizard.step]); + } const policyEngineItems: SelectableItem[] = useMemo( () => [ diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 14195d6f5..59be5abe7 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -6,7 +6,6 @@ import type { GatewayPolicyEngineConfiguration, GatewayTargetType, NodeRuntime, - PythonRuntime, SchemaSource, ToolDefinition, } from '../../../../schema'; @@ -278,7 +277,8 @@ export const POLICY_ENGINE_MODE_OPTIONS = [ ] as const; export const PYTHON_VERSION_OPTIONS = [ - { id: 'PYTHON_3_13', title: 'Python 3.13', description: 'Latest' }, + { id: 'PYTHON_3_14', title: 'Python 3.14', description: 'Latest' }, + { id: 'PYTHON_3_13', title: 'Python 3.13', description: '' }, { id: 'PYTHON_3_12', title: 'Python 3.12', description: '' }, { id: 'PYTHON_3_11', title: 'Python 3.11', description: '' }, { id: 'PYTHON_3_10', title: 'Python 3.10', description: '' }, @@ -294,6 +294,6 @@ export const NODE_VERSION_OPTIONS = [ // Defaults // ───────────────────────────────────────────────────────────────────────────── -export const DEFAULT_PYTHON_VERSION: PythonRuntime = 'PYTHON_3_13'; +export { DEFAULT_PYTHON_VERSION } from '../../../../schema'; export const DEFAULT_NODE_VERSION: NodeRuntime = 'NODE_20'; export const DEFAULT_HANDLER = 'handler.lambda_handler'; diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx index 2740027e1..dbbd1f772 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/policy/AddPolicyEngineScreen.tsx b/src/cli/tui/screens/policy/AddPolicyEngineScreen.tsx index 98813804e..e300a359d 100644 --- a/src/cli/tui/screens/policy/AddPolicyEngineScreen.tsx +++ b/src/cli/tui/screens/policy/AddPolicyEngineScreen.tsx @@ -9,6 +9,7 @@ interface AddPolicyEngineScreenProps { onComplete: (config: AddPolicyEngineConfig) => void; onExit: () => void; existingEngineNames: string[]; + initialName?: string; headerContent?: React.ReactNode; } @@ -16,6 +17,7 @@ export function AddPolicyEngineScreen({ onComplete, onExit, existingEngineNames, + initialName, headerContent, }: AddPolicyEngineScreenProps) { return ( @@ -24,7 +26,7 @@ export function AddPolicyEngineScreen({ onComplete({ name })} onCancel={onExit} schema={PolicyEngineNameSchema} diff --git a/src/cli/tui/screens/policy/AddPolicyFlow.tsx b/src/cli/tui/screens/policy/AddPolicyFlow.tsx index 5e7aa9916..720984563 100644 --- a/src/cli/tui/screens/policy/AddPolicyFlow.tsx +++ b/src/cli/tui/screens/policy/AddPolicyFlow.tsx @@ -56,6 +56,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD const [engineNames, setEngineNames] = useState([]); const [policyNames, setPolicyNames] = useState([]); const [hasUnprotectedGateways, setHasUnprotectedGateways] = useState(false); + const [pendingEngineName, setPendingEngineName] = useState(); const engineSteps = useMemo(() => { const steps: EngineCreationStep[] = ['name']; @@ -126,23 +127,32 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD } }, []); - const handleEngineComplete = useCallback(async (config: AddPolicyEngineConfig) => { - const result = await policyEnginePrimitive.add({ - name: config.name, - }); + const commitEngine = useCallback(async (engineName: string, gateways?: string[], mode?: 'LOG_ONLY' | 'ENFORCE') => { + const result = await policyEnginePrimitive.add({ name: engineName }); + if (!result.success) { + setFlow({ name: 'error', message: result.error }); + return; + } + setEngineNames(prev => [...prev, engineName]); + setPendingEngineName(undefined); + if (gateways && gateways.length > 0 && mode) { + await policyEnginePrimitive.attachToGateways(engineName, gateways, mode); + } + setFlow({ name: 'engine-success', engineName }); + }, []); - if (result.success) { - setEngineNames(prev => [...prev, config.name]); + const handleEngineComplete = useCallback( + async (config: AddPolicyEngineConfig) => { + setPendingEngineName(config.name); const unprotected = await policyEnginePrimitive.getUnprotectedGateways(); if (unprotected.length > 0) { setFlow({ name: 'attach-gateways', engineName: config.name, gateways: unprotected }); } else { - setFlow({ name: 'engine-success', engineName: config.name }); + void commitEngine(config.name); } - } else { - setFlow({ name: 'error', message: result.error }); - } - }, []); + }, + [commitEngine] + ); const handlePolicyComplete = useCallback(async (config: AddPolicyConfig) => { const result = await policyPrimitive.add({ @@ -201,6 +211,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD return ( } onComplete={(config: AddPolicyEngineConfig) => void handleEngineComplete(config)} onExit={() => { @@ -238,7 +249,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD stepIndicator={} onConfirm={selected => { if (selected.length === 0) { - setFlow({ name: 'engine-success', engineName: flow.engineName }); + void commitEngine(flow.engineName); } else { setFlow({ name: 'attach-mode', @@ -248,7 +259,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD }); } }} - onSkip={() => setFlow({ name: 'engine-success', engineName: flow.engineName })} + onBack={() => setFlow({ name: 'engine-wizard' })} /> ); } @@ -261,15 +272,12 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD gatewayCount={flow.selectedGateways.length} stepIndicator={} onSelect={mode => { - void policyEnginePrimitive - .attachToGateways(flow.engineName, flow.selectedGateways, mode) - .then(() => setFlow({ name: 'engine-success', engineName: flow.engineName })) - .catch(err => - setFlow({ - name: 'error', - message: err instanceof Error ? err.message : 'Failed to attach policy engine', - }) - ); + void commitEngine(flow.engineName, flow.selectedGateways, mode).catch(err => + setFlow({ + name: 'error', + message: err instanceof Error ? err.message : 'Failed to attach policy engine', + }) + ); }} onBack={() => { setFlow({ name: 'attach-gateways', engineName: flow.engineName, gateways: flow.allGateways }); @@ -360,13 +368,13 @@ function AttachGatewaysScreen({ engineName, gateways, onConfirm, - onSkip, + onBack, stepIndicator, }: { engineName: string; gateways: string[]; onConfirm: (selected: string[]) => void; - onSkip: () => void; + onBack: () => void; stepIndicator?: React.ReactNode; }) { const items: SelectableItem[] = useMemo(() => gateways.map(name => ({ id: name, title: name })), [gateways]); @@ -375,7 +383,7 @@ function AttachGatewaysScreen({ items, getId: item => item.id, onConfirm: ids => onConfirm([...ids]), - onExit: onSkip, + onExit: onBack, isActive: true, requireSelection: false, }); @@ -383,8 +391,8 @@ function AttachGatewaysScreen({ return ( diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index a9ba3ff16..7001fde27 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -12,6 +12,7 @@ import { useRemovableOnlineEvalConfigs, useRemovablePolicies, useRemovablePolicyEngines, + useRemovableRuntimeEndpoints, useRemovalPreview, useRemoveABTest, useRemoveAgent, @@ -24,6 +25,7 @@ import { useRemoveOnlineEvalConfig, useRemovePolicy, useRemovePolicyEngine, + useRemoveRuntimeEndpoint, } from '../../hooks/useRemove'; import { RemoveABTestScreen } from '../ab-test/RemoveABTestScreen'; import { RemoveAgentScreen } from './RemoveAgentScreen'; @@ -38,6 +40,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'; @@ -58,6 +61,7 @@ type FlowState = | { 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 } | { name: 'confirm-gateway-target'; tool: RemovableGatewayTarget; preview: RemovalPreview } @@ -69,6 +73,7 @@ type FlowState = | { 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 } | { name: 'gateway-success'; gatewayName: string; logFilePath?: string } @@ -81,6 +86,7 @@ type FlowState = | { 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,6 +103,7 @@ interface RemoveFlowProps { | 'agent' | 'gateway' | 'gateway-target' + | 'runtime-endpoint' | 'memory' | 'credential' | 'evaluator' @@ -142,6 +149,8 @@ export function RemoveFlow({ return { name: 'select-config-bundle' }; case 'ab-test': return { name: 'select-ab-test' }; + case 'runtime-endpoint': + return { name: 'select-runtime-endpoint' }; default: return { name: 'select' }; } @@ -172,6 +181,11 @@ export function RemoveFlow({ refresh: refreshConfigBundles, } = useRemovableConfigBundles(); const { abTests } = useRemovableABTests(); + const { + endpoints: runtimeEndpoints, + isLoading: isLoadingRuntimeEndpoints, + refresh: refreshRuntimeEndpoints, + } = useRemovableRuntimeEndpoints(); // Check if any data is still loading const isLoading = @@ -184,7 +198,8 @@ export function RemoveFlow({ isLoadingOnlineEvals || isLoadingPolicyEngines || isLoadingPolicies || - isLoadingConfigBundles; + isLoadingConfigBundles || + isLoadingRuntimeEndpoints; // Preview hook const { @@ -199,6 +214,7 @@ export function RemoveFlow({ loadPolicyPreview, loadConfigBundlePreview, loadABTestPreview, + loadRuntimeEndpointPreview, reset: resetPreview, } = useRemovalPreview(); @@ -214,6 +230,7 @@ export function RemoveFlow({ 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 const pendingResultRef = useRef(null); @@ -246,6 +263,7 @@ export function RemoveFlow({ 'policy-success', 'config-bundle-success', 'ab-test-success', + 'runtime-endpoint-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -291,6 +309,9 @@ export function RemoveFlow({ case 'ab-test': setFlow({ name: 'select-ab-test' }); break; + case 'runtime-endpoint': + setFlow({ name: 'select-runtime-endpoint' }); + break; case 'all': setFlow({ name: 'remove-all' }); break; @@ -545,6 +566,28 @@ export function RemoveFlow({ [loadABTestPreview, force, removeABTestOp] ); + 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) { @@ -587,6 +630,9 @@ export function RemoveFlow({ case 'ab-test': void handleSelectABTest(initialResourceName); break; + case 'runtime-endpoint': + void handleSelectRuntimeEndpoint(initialResourceName); + break; } }, 0); }, [ @@ -603,6 +649,7 @@ export function RemoveFlow({ handleSelectPolicy, handleSelectConfigBundle, handleSelectABTest, + handleSelectRuntimeEndpoint, ]); // Confirm handlers - pass preview for logging @@ -782,6 +829,22 @@ export function RemoveFlow({ [removeABTestOp] ); + 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(); @@ -795,6 +858,7 @@ export function RemoveFlow({ resetRemovePolicy(); resetRemoveConfigBundle(); resetRemoveABTest(); + resetRemoveRuntimeEndpoint(); }, [ resetPreview, resetRemoveAgent, @@ -808,6 +872,7 @@ export function RemoveFlow({ resetRemovePolicy, resetRemoveConfigBundle, resetRemoveABTest, + resetRemoveRuntimeEndpoint, ]); const refreshAll = useCallback(async () => { @@ -822,6 +887,7 @@ export function RemoveFlow({ refreshPolicyEngines(), refreshPolicies(), refreshConfigBundles(), + refreshRuntimeEndpoints(), ]); }, [ refreshAgents, @@ -834,6 +900,7 @@ export function RemoveFlow({ refreshPolicyEngines, refreshPolicies, refreshConfigBundles, + refreshRuntimeEndpoints, ]); // Select screen - wait for data to load to avoid arrow position issues @@ -856,6 +923,7 @@ export function RemoveFlow({ policyCount={policies.length} configBundleCount={configBundles.length} abTestCount={abTests.length} + runtimeEndpointCount={runtimeEndpoints.length} /> ); } @@ -1016,6 +1084,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 ( @@ -1138,6 +1219,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 ( @@ -1315,6 +1407,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 8ac1a57aa..bf0df4199 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -14,6 +14,7 @@ const REMOVE_RESOURCES = [ { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, { id: 'config-bundle', title: 'Configuration Bundle', description: 'Remove a configuration bundle' }, { id: 'ab-test', title: 'AB Test', 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; @@ -44,6 +45,8 @@ interface RemoveScreenProps { configBundleCount: number; /** Number of AB tests available for removal */ abTestCount: number; + /** Number of runtime endpoints available for removal */ + runtimeEndpointCount: number; } export function RemoveScreen({ @@ -60,6 +63,7 @@ export function RemoveScreen({ policyCount, configBundleCount, abTestCount, + runtimeEndpointCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { @@ -133,6 +137,12 @@ export function RemoveScreen({ description = 'No AB tests 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; @@ -152,6 +162,7 @@ export function RemoveScreen({ policyCount, configBundleCount, abTestCount, + 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 237e802e2..ccc59e9da 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -23,6 +23,7 @@ describe('RemoveScreen', () => { policyCount={1} configBundleCount={1} abTestCount={0} + runtimeEndpointCount={1} /> ); @@ -55,6 +56,7 @@ describe('RemoveScreen', () => { policyCount={0} configBundleCount={0} abTestCount={0} + runtimeEndpointCount={0} /> ); @@ -83,6 +85,7 @@ describe('RemoveScreen', () => { policyCount={0} configBundleCount={0} abTestCount={2} + runtimeEndpointCount={0} /> ); @@ -109,6 +112,7 @@ describe('RemoveScreen', () => { policyCount={0} configBundleCount={0} abTestCount={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/cli/tui/utils/commands.ts b/src/cli/tui/utils/commands.ts index af228532e..c9d99272e 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -12,7 +12,7 @@ export interface CommandMeta { /** * Commands hidden from TUI entirely (meta commands). */ -const HIDDEN_FROM_TUI = ['help', 'import', 'telemetry'] as const; +const HIDDEN_FROM_TUI = ['help', 'telemetry'] as const; /** * Commands that are CLI-only (shown but marked as requiring CLI invocation). diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8916c6052..48b5feb48 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -47,16 +47,6 @@ export const CONTAINER_INTERNAL_PORT = 8080; export type ContainerRuntime = 'docker' | 'podman' | 'finch'; export const CONTAINER_RUNTIMES: ContainerRuntime[] = ['docker', 'podman', 'finch']; -/** Platform-aware start hints for container runtimes. */ -export const START_HINTS: Record = { - docker: - process.platform === 'win32' - ? 'Start Docker Desktop or run: Start-Service docker' - : 'Start Docker Desktop or run: sudo systemctl start docker', - podman: 'Run: podman machine start', - finch: 'Run: finch vm init && finch vm start', -}; - /** * Get the Dockerfile path for a given code location. * @param codeLocation - Directory containing the Dockerfile diff --git a/src/lib/errors/config.ts b/src/lib/errors/config.ts index ec75edd69..4d7ccebe0 100644 --- a/src/lib/errors/config.ts +++ b/src/lib/errors/config.ts @@ -150,8 +150,11 @@ function formatZodIssue(issue: ZodIssueExt): string { } } - // Fail open: unhandled cases use Zod's message verbatim - return `${path}: ${issue.message}`; + // Fail open: unhandled cases use Zod's message verbatim. + // Some Zod 3/4 shape mismatches produce issues with no .message — fall back to the code + // so users see something actionable instead of the literal string "undefined". + const message = issue.message ?? issue.code ?? 'invalid value'; + return `${path}: ${message}`; } /** diff --git a/src/lib/index.ts b/src/lib/index.ts index 3e98af372..cab20d720 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -16,7 +16,6 @@ export { DOCKERFILE_NAME, CONTAINER_INTERNAL_PORT, CONTAINER_RUNTIMES, - START_HINTS, getDockerfilePath, type ContainerRuntime, } from './constants'; diff --git a/src/lib/packaging/__tests__/helpers.test.ts b/src/lib/packaging/__tests__/helpers.test.ts index efb8e88c8..a554edb0e 100644 --- a/src/lib/packaging/__tests__/helpers.test.ts +++ b/src/lib/packaging/__tests__/helpers.test.ts @@ -16,7 +16,9 @@ import { resolveProjectPaths, resolveProjectPathsSync, } from '../helpers.js'; +import { unzipSync } from 'fflate'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { readFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -388,3 +390,129 @@ describe('convertWindowsScriptsToLinux (shebang rewriting on non-Windows)', () = expect(existsSync(join(staging, 'bin'))).toBe(false); }); }); + +// ── Issue #843: nested agentcore directory exclusion ──────────────── + +describe('nested agentcore directory is preserved (issue #843)', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'helpers-nested-agentcore-')); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + /** + * Helper: build a source tree that mimics a real project with a + * top-level agentcore/ config dir AND a third-party dependency + * that ships its own agentcore/ sub-module. + * + * / + * main.py + * agentcore/ ← should be excluded (project config dir) + * config.yaml + * lib/ + * langgraph_checkpoint_aws/ + * __init__.py + * agentcore/ ← should be INCLUDED (dependency sub-module) + * __init__.py + * core.py + */ + function buildFixture(base: string): string { + const src = join(base, 'src'); + + // Top-level source file + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'main.py'), 'print("hello")'); + + // Top-level agentcore/ (project config — should be excluded) + mkdirSync(join(src, 'agentcore'), { recursive: true }); + writeFileSync(join(src, 'agentcore', 'config.yaml'), 'key: value'); + + // Nested dependency with its own agentcore/ sub-module + const nestedAgentcore = join(src, 'lib', 'langgraph_checkpoint_aws', 'agentcore'); + mkdirSync(nestedAgentcore, { recursive: true }); + writeFileSync(join(src, 'lib', 'langgraph_checkpoint_aws', '__init__.py'), '# init'); + writeFileSync(join(nestedAgentcore, '__init__.py'), '# agentcore init'); + writeFileSync(join(nestedAgentcore, 'core.py'), 'class Core: pass'); + + return src; + } + + // ── copySourceTree (async) ── + + it('excludes top-level agentcore/ but includes nested agentcore/', async () => { + const src = buildFixture(join(root, 'copy-async')); + const dest = join(root, 'copy-async-dest'); + mkdirSync(dest, { recursive: true }); + + await copySourceTree(src, dest); + + // Top-level agentcore/ excluded + expect(existsSync(join(dest, 'agentcore'))).toBe(false); + + // Source file preserved + expect(existsSync(join(dest, 'main.py'))).toBe(true); + + // Nested agentcore/ inside dependency preserved + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', '__init__.py'))).toBe(true); + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', 'core.py'))).toBe(true); + }); + + // ── copySourceTreeSync ── + + it('sync: excludes top-level agentcore/ but includes nested agentcore/', () => { + const src = buildFixture(join(root, 'copy-sync')); + const dest = join(root, 'copy-sync-dest'); + mkdirSync(dest, { recursive: true }); + + copySourceTreeSync(src, dest); + + expect(existsSync(join(dest, 'agentcore'))).toBe(false); + expect(existsSync(join(dest, 'main.py'))).toBe(true); + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', '__init__.py'))).toBe(true); + expect(existsSync(join(dest, 'lib', 'langgraph_checkpoint_aws', 'agentcore', 'core.py'))).toBe(true); + }); + + // ── createZipFromDir (async) ── + + it('zip: excludes top-level agentcore/ but includes nested agentcore/', async () => { + const src = buildFixture(join(root, 'zip-async')); + const zipPath = join(root, 'zip-async.zip'); + + await createZipFromDir(src, zipPath); + + const zipBuffer = await readFile(zipPath); + const entries = Object.keys(unzipSync(new Uint8Array(zipBuffer))); + + // Top-level agentcore/ should NOT appear + expect(entries.some(e => e === 'agentcore/config.yaml')).toBe(false); + expect(entries.some(e => e.startsWith('agentcore/'))).toBe(false); + + // Nested agentcore/ SHOULD appear + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/__init__.py'); + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/core.py'); + + // Regular files present + expect(entries).toContain('main.py'); + }); + + // ── createZipFromDirSync ── + + it('sync zip: excludes top-level agentcore/ but includes nested agentcore/', () => { + const src = buildFixture(join(root, 'zip-sync')); + const zipPath = join(root, 'zip-sync.zip'); + + createZipFromDirSync(src, zipPath); + + const zipBuffer = readFileSync(zipPath); + const entries = Object.keys(unzipSync(new Uint8Array(zipBuffer))); + + expect(entries.some(e => e.startsWith('agentcore/'))).toBe(false); + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/__init__.py'); + expect(entries).toContain('lib/langgraph_checkpoint_aws/agentcore/core.py'); + expect(entries).toContain('main.py'); + }); +}); diff --git a/src/lib/packaging/__tests__/python.test.ts b/src/lib/packaging/__tests__/python.test.ts index 2a912be28..1640e9581 100644 --- a/src/lib/packaging/__tests__/python.test.ts +++ b/src/lib/packaging/__tests__/python.test.ts @@ -19,6 +19,10 @@ describe('extractPythonVersion', () => { expect(extractPythonVersion('PYTHON_3_13' as PythonRuntime)).toBe('3.13'); }); + it('extracts 3.14 from PYTHON_3_14', () => { + expect(extractPythonVersion('PYTHON_3_14' as PythonRuntime)).toBe('3.14'); + }); + it('throws for unsupported runtime string', () => { expect(() => extractPythonVersion('RUBY_3_0' as PythonRuntime)).toThrow('Unsupported Python runtime'); }); diff --git a/src/lib/packaging/__tests__/uv.test.ts b/src/lib/packaging/__tests__/uv.test.ts index a64cabb6a..143a428f5 100644 --- a/src/lib/packaging/__tests__/uv.test.ts +++ b/src/lib/packaging/__tests__/uv.test.ts @@ -57,6 +57,20 @@ describe('detectUnavailablePlatform', () => { expect(issue!.message).toContain('has no wheels'); }); + it('detects "no wheels with a matching Python ABI tag" message (e.g. cp314)', () => { + const out = 'numpy==2.4.4 has no wheels with a matching Python ABI tag (e.g., `cp314`)'; + const issue = detectUnavailablePlatform(result(out)); + expect(issue).not.toBeNull(); + expect(issue!.message).toContain('cp314'); + }); + + it('detects "has no usable wheels" message', () => { + const out = 'numpy>=1.10.4 has no usable wheels, we can conclude that numpy>=1.10.4 cannot be used.'; + const issue = detectUnavailablePlatform(result(out)); + expect(issue).not.toBeNull(); + expect(issue!.message).toContain('usable wheels'); + }); + it('returns null for successful output', () => { const out = 'Successfully installed package-1.0.0\nAll done!'; expect(detectUnavailablePlatform({ code: 0, stdout: out, stderr: '', signal: null })).toBeNull(); diff --git a/src/lib/packaging/helpers.ts b/src/lib/packaging/helpers.ts index b2a2fe902..31c74b298 100644 --- a/src/lib/packaging/helpers.ts +++ b/src/lib/packaging/helpers.ts @@ -51,15 +51,7 @@ interface ResolvedPaths { artifactsDir: string; } -const EXCLUDED_ENTRIES = new Set([ - 'agentcore', - '.git', - '.venv', - '__pycache__', - '.pytest_cache', - '.DS_Store', - 'node_modules', -]); +const EXCLUDED_ENTRIES = new Set(['.git', '.venv', '__pycache__', '.pytest_cache', '.DS_Store', 'node_modules']); export const MAX_ZIP_SIZE_BYTES = 250 * 1024 * 1024; @@ -147,7 +139,7 @@ export async function ensureDirClean(dir: string): Promise { await mkdir(dir, { recursive: true }); } -async function copyEntry(source: string, destination: string): Promise { +async function copyEntry(source: string, destination: string, rootDir: string): Promise { const stats = await stat(source); if (stats.isDirectory()) { await mkdir(destination, { recursive: true }); @@ -156,7 +148,10 @@ async function copyEntry(source: string, destination: string): Promise { if (EXCLUDED_ENTRIES.has(entry)) { continue; } - await copyEntry(join(source, entry), join(destination, entry)); + if (entry === CONFIG_DIR && resolve(source) === resolve(rootDir)) { + continue; + } + await copyEntry(join(source, entry), join(destination, entry), rootDir); } return; } @@ -170,7 +165,7 @@ export async function copySourceTree(srcDir: string, destination: string): Promi if (!(await pathExists(srcDir))) { throw new MissingProjectFileError(srcDir); } - await copyEntry(srcDir, destination); + await copyEntry(srcDir, destination, srcDir); } export async function ensureBinaryAvailable(binary: string, installHint?: string): Promise { @@ -197,23 +192,24 @@ export async function createZipFromDir(sourceDir: string, outputZip: string): Pr await rm(outputZip, { force: true }); await mkdir(dirname(outputZip), { recursive: true }); - const files = await collectFiles(sourceDir); + const files = await collectFiles(sourceDir, sourceDir); const zipped = zipSync(files); await writeFile(outputZip, zipped); } -async function collectFiles(directory: string, basePath = ''): Promise { +async function collectFiles(directory: string, rootDir: string, basePath = ''): Promise { const result: Zippable = {}; const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (EXCLUDED_ENTRIES.has(entry.name)) continue; + if (entry.name === CONFIG_DIR && resolve(directory) === resolve(rootDir)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { - Object.assign(result, await collectFiles(fullPath, zipPath)); + Object.assign(result, await collectFiles(fullPath, rootDir, zipPath)); } else if (entry.isFile()) { result[zipPath] = [await readFile(fullPath), { level: 6 }]; } @@ -286,7 +282,7 @@ export function ensureDirCleanSync(dir: string): void { mkdirSync(dir, { recursive: true }); } -function copyEntrySync(source: string, destination: string): void { +function copyEntrySync(source: string, destination: string, rootDir: string): void { const stats = statSync(source); if (stats.isDirectory()) { mkdirSync(destination, { recursive: true }); @@ -295,7 +291,10 @@ function copyEntrySync(source: string, destination: string): void { if (EXCLUDED_ENTRIES.has(entry)) { continue; } - copyEntrySync(join(source, entry), join(destination, entry)); + if (entry === CONFIG_DIR && resolve(source) === resolve(rootDir)) { + continue; + } + copyEntrySync(join(source, entry), join(destination, entry), rootDir); } return; } @@ -307,7 +306,7 @@ export function copySourceTreeSync(srcDir: string, destination: string): void { if (!pathExistsSync(srcDir)) { throw new MissingProjectFileError(srcDir); } - copyEntrySync(srcDir, destination); + copyEntrySync(srcDir, destination, srcDir); } export function ensureBinaryAvailableSync(binary: string, installHint?: string): void { @@ -326,18 +325,19 @@ export function ensureBinaryAvailableSync(binary: string, installHint?: string): throw new MissingDependencyError(binary, installHint); } -function collectFilesSync(directory: string, basePath = ''): Zippable { +function collectFilesSync(directory: string, rootDir: string, basePath = ''): Zippable { const result: Zippable = {}; const entries = readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { if (EXCLUDED_ENTRIES.has(entry.name)) continue; + if (entry.name === CONFIG_DIR && resolve(directory) === resolve(rootDir)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { - Object.assign(result, collectFilesSync(fullPath, zipPath)); + Object.assign(result, collectFilesSync(fullPath, rootDir, zipPath)); } else if (entry.isFile()) { result[zipPath] = [readFileSync(fullPath), { level: 6 }]; } @@ -349,7 +349,7 @@ export function createZipFromDirSync(sourceDir: string, outputZip: string): void rmSync(outputZip, { force: true }); mkdirSync(dirname(outputZip), { recursive: true }); - const files = collectFilesSync(sourceDir); + const files = collectFilesSync(sourceDir, sourceDir); const zipped = zipSync(files); writeFileSync(outputZip, zipped); } diff --git a/src/lib/packaging/index.ts b/src/lib/packaging/index.ts index e90413962..a281bbdbb 100644 --- a/src/lib/packaging/index.ts +++ b/src/lib/packaging/index.ts @@ -1,4 +1,5 @@ import type { AgentCoreProjectSpec, AgentEnvSpec, RuntimeVersion } from '../../schema'; +import { DEFAULT_PYTHON_VERSION } from '../../schema'; import { ContainerPackager } from './container'; import { PackagingError } from './errors'; import { isNodeRuntime, isPythonRuntime } from './helpers'; @@ -78,7 +79,7 @@ export async function packRuntime(spec: AgentEnvSpec, options?: PackageOptions): * Defaults to Python if no runtimeVersion is specified. */ export function packCodeZipSync(config: CodeBundleConfig | AgentEnvSpec, options?: PackageOptions): ArtifactResult { - const runtimeVersion = config.runtimeVersion ?? 'PYTHON_3_13'; + const runtimeVersion = config.runtimeVersion ?? DEFAULT_PYTHON_VERSION; const packager = getCodeZipPackager(runtimeVersion); return packager.packCodeZip(config as AgentEnvSpec, options); } diff --git a/src/lib/packaging/python.ts b/src/lib/packaging/python.ts index c64eb9a85..784356e50 100644 --- a/src/lib/packaging/python.ts +++ b/src/lib/packaging/python.ts @@ -1,4 +1,5 @@ import type { AgentEnvSpec, PythonRuntime, RuntimeVersion } from '../../schema'; +import { DEFAULT_PYTHON_VERSION } from '../../schema'; import { UV_INSTALL_HINT, getArtifactZipName } from '../constants'; import { runSubprocessCapture, runSubprocessCaptureSync } from '../utils/subprocess'; import { PackagingError } from './errors'; @@ -176,7 +177,7 @@ export class PythonCodeZipPackager implements RuntimePackager { */ export class PythonCodeZipPackagerSync implements CodeZipPackager { packCodeZip(config: AgentEnvSpec, options: PackageOptions = {}): ArtifactResult { - const runtimeVersion = config.runtimeVersion ?? 'PYTHON_3_13'; + const runtimeVersion = config.runtimeVersion ?? DEFAULT_PYTHON_VERSION; if (!isPythonRuntimeVersion(runtimeVersion)) { throw new PackagingError(`Python packager only supports Python runtimes. Received: ${runtimeVersion}`); diff --git a/src/lib/packaging/types/fflate.d.ts b/src/lib/packaging/types/fflate.d.ts deleted file mode 100644 index 32720ed88..000000000 --- a/src/lib/packaging/types/fflate.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare module 'fflate' { - export interface ZipOptions { - level?: number; - } - - export type ZippableFile = Uint8Array | string | [Uint8Array | string, ZipOptions]; - - export interface Zippable { - [path: string]: ZippableFile | Zippable; - } - - export function zipSync(data: Zippable, options?: ZipOptions): Uint8Array; -} diff --git a/src/lib/packaging/uv.ts b/src/lib/packaging/uv.ts index 19e7fa0d8..1b6c0c2f6 100644 --- a/src/lib/packaging/uv.ts +++ b/src/lib/packaging/uv.ts @@ -7,7 +7,8 @@ export interface PlatformIssue { const PLATFORM_HINT_REGEX = /platforms:\s*([^\n]+)/i; const MANYLINUX_TOKEN = /(manylinux[^\s,]+)/gi; -const NO_WHEELS_REGEX = /(has no wheels with a matching platform tag|no compatible (?:wheels|tags) found)/i; +const NO_WHEELS_REGEX = + /(has no wheels with a matching (?:platform|Python ABI) tag|no compatible (?:wheels|tags) found|has no usable wheels)/i; export function detectUnavailablePlatform(result: SubprocessResult): PlatformIssue | null { const combined = `${result.stdout}\n${result.stderr}`; diff --git a/src/schema/__tests__/constants.test.ts b/src/schema/__tests__/constants.test.ts index affa85b58..546770b6b 100644 --- a/src/schema/__tests__/constants.test.ts +++ b/src/schema/__tests__/constants.test.ts @@ -60,13 +60,13 @@ describe('ModelProviderSchema', () => { }); describe('PythonRuntimeSchema', () => { - it.each(['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13'])('accepts "%s"', version => { + 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); }); it('rejects unsupported versions', () => { expect(PythonRuntimeSchema.safeParse('PYTHON_3_9').success).toBe(false); - expect(PythonRuntimeSchema.safeParse('PYTHON_3_14').success).toBe(false); + expect(PythonRuntimeSchema.safeParse('PYTHON_3_15').success).toBe(false); }); }); @@ -163,8 +163,8 @@ describe('isReservedProjectName', () => { describe('PROTOCOL_FRAMEWORK_MATRIX', () => { it('defines all protocol modes', () => { - expect(Object.keys(PROTOCOL_FRAMEWORK_MATRIX)).toEqual(expect.arrayContaining(['HTTP', 'MCP', 'A2A'])); - expect(Object.keys(PROTOCOL_FRAMEWORK_MATRIX)).toHaveLength(3); + expect(Object.keys(PROTOCOL_FRAMEWORK_MATRIX)).toEqual(expect.arrayContaining(['HTTP', 'MCP', 'A2A', 'AGUI'])); + expect(Object.keys(PROTOCOL_FRAMEWORK_MATRIX)).toHaveLength(4); }); it('HTTP supports all visible frameworks', () => { diff --git a/src/schema/constants.ts b/src/schema/constants.ts index ea159ec21..d235a0df1 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -99,6 +99,12 @@ export const RESERVED_PROJECT_NAMES: readonly string[] = [ 'strands', 'strandsagents', 'strandsagentstools', + // AG-UI adapter packages + 'agui', + 'aguistrands', + 'aguilanggraph', + 'aguiadk', + 'aguiprotocol', // Common utilities 'httpx', 'pytest', @@ -137,9 +143,12 @@ export function isReservedProjectName(name: string): boolean { // Infrastructure Constants (shared between agent-env and mcp schemas) // ============================================================================ -export const PythonRuntimeSchema = z.enum(['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13']); +export const PythonRuntimeSchema = z.enum(['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13', 'PYTHON_3_14']); export type PythonRuntime = z.infer; +/** Default Python runtime version for new agents and MCP tools */ +export const DEFAULT_PYTHON_VERSION: PythonRuntime = 'PYTHON_3_14'; + export const NodeRuntimeSchema = z.enum(['NODE_18', 'NODE_20', 'NODE_22']); export type NodeRuntime = z.infer; @@ -154,7 +163,7 @@ export type NetworkMode = z.infer; // Protocol Mode // ============================================================================ -export const ProtocolModeSchema = z.enum(['HTTP', 'MCP', 'A2A']); +export const ProtocolModeSchema = z.enum(['HTTP', 'MCP', 'A2A', 'AGUI']); export type ProtocolMode = z.infer; /** @@ -165,6 +174,7 @@ export const PROTOCOL_FRAMEWORK_MATRIX: Record; } @@ -89,7 +92,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[]; // Unique by type. Can be empty (short-term memory). tags?: Record; encryptionKeyArn?: string; diff --git a/src/schema/llm-compacted/aws-targets.ts b/src/schema/llm-compacted/aws-targets.ts index 5e01862b8..526a1a695 100644 --- a/src/schema/llm-compacted/aws-targets.ts +++ b/src/schema/llm-compacted/aws-targets.ts @@ -23,15 +23,23 @@ interface AwsDeploymentTarget { // ───────────────────────────────────────────────────────────────────────────── // SUPPORTED REGIONS +// https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-regions.html // ───────────────────────────────────────────────────────────────────────────── type AgentCoreRegion = | '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-west-2' + | 'us-gov-west-1'; diff --git a/src/schema/llm-compacted/mcp.ts b/src/schema/llm-compacted/mcp.ts index 16a613883..9e1e8d8a2 100644 --- a/src/schema/llm-compacted/mcp.ts +++ b/src/schema/llm-compacted/mcp.ts @@ -177,6 +177,6 @@ interface IamPolicyDocument { // ───────────────────────────────────────────────────────────────────────────── type GatewayTargetType = 'lambda' | 'mcpServer' | 'openApiSchema' | 'smithyModel' | 'apiGateway' | 'lambdaFunctionArn'; -type PythonRuntime = 'PYTHON_3_10' | 'PYTHON_3_11' | 'PYTHON_3_12' | 'PYTHON_3_13'; +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__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 477c2489a..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'; @@ -209,7 +211,7 @@ describe('AgentEnvSpecSchema', () => { }); it('accepts agent with all Python runtime versions', () => { - for (const version of ['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13']) { + for (const version of ['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13', 'PYTHON_3_14']) { const result = AgentEnvSpecSchema.safeParse({ ...validPythonAgent, runtimeVersion: version }); expect(result.success, `Should accept ${version}`).toBe(true); } @@ -286,7 +288,7 @@ describe('AgentEnvSpecSchema', () => { }); describe('protocol', () => { - it.each(['HTTP', 'MCP', 'A2A'])('accepts valid protocol "%s"', mode => { + 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); }); @@ -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__/aws-targets.test.ts b/src/schema/schemas/__tests__/aws-targets.test.ts index 3fc9cbfea..cd84f9cb3 100644 --- a/src/schema/schemas/__tests__/aws-targets.test.ts +++ b/src/schema/schemas/__tests__/aws-targets.test.ts @@ -10,14 +10,21 @@ 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 => { @@ -26,8 +33,8 @@ describe('AgentCoreRegionSchema', () => { it('rejects unsupported regions', () => { expect(AgentCoreRegionSchema.safeParse('us-west-1').success).toBe(false); - expect(AgentCoreRegionSchema.safeParse('eu-west-2').success).toBe(false); - expect(AgentCoreRegionSchema.safeParse('sa-east-1').success).toBe(false); + expect(AgentCoreRegionSchema.safeParse('af-south-1').success).toBe(false); + expect(AgentCoreRegionSchema.safeParse('me-south-1').success).toBe(false); }); it('rejects empty string', () => { 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 fc2c54d1e..789109a38 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -142,6 +142,23 @@ export const RequestHeaderAllowlistSchema = z ) .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`); +/** + * Session storage configuration for filesystem persistence. + * Files written to mountPath persist across session stop/resume cycles. + */ +export const SessionStorageSchema = z.object({ + /** Absolute mount path under /mnt with exactly one subdirectory level (e.g. /mnt/data). */ + mountPath: z + .string() + .regex(/^\/mnt\/[^/]+$/, 'Must be a path under /mnt with exactly one subdirectory (e.g. /mnt/data)'), +}); +export type SessionStorage = z.infer; + +export const FilesystemConfigurationSchema = z.object({ + sessionStorage: SessionStorageSchema, +}); +export type FilesystemConfiguration = z.infer; + /** Minimum allowed value for lifecycle timeout fields (seconds). */ export const LIFECYCLE_TIMEOUT_MIN = 60; /** Maximum allowed value for lifecycle timeout fields (seconds). */ @@ -171,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. @@ -199,7 +241,7 @@ export const AgentEnvSpecSchema = z networkConfig: NetworkConfigSchema.optional(), /** Instrumentation settings for observability. Defaults to OTel enabled. */ instrumentation: InstrumentationSchema.optional(), - /** Protocol for the runtime (HTTP, MCP, A2A). */ + /** Protocol for the runtime (HTTP, MCP, A2A, AGUI). */ protocol: ProtocolModeSchema.optional(), /** Allowed request headers forwarded to the runtime at invocation time. */ requestHeaderAllowlist: RequestHeaderAllowlistSchema.optional(), @@ -212,6 +254,10 @@ export const AgentEnvSpecSchema = z tags: TagsSchema.optional(), /** Lifecycle configuration for runtime sessions. */ 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 f27874b11..53407cc25 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -132,7 +132,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/aws-targets.ts b/src/schema/schemas/aws-targets.ts index 8b2c91b8f..711e208dc 100644 --- a/src/schema/schemas/aws-targets.ts +++ b/src/schema/schemas/aws-targets.ts @@ -3,18 +3,26 @@ import { z } from 'zod'; // ============================================================================ // AgentCore Regions +// Keep in sync with: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-regions.html // ============================================================================ export const AgentCoreRegionSchema = z.enum([ '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', ]); export type AgentCoreRegion = z.infer; diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index e2d19491a..b22478bb1 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; @@ -210,6 +212,17 @@ export const HttpGatewayDeployedStateSchema = z.object({ export type HttpGatewayDeployedState = 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 // ============================================================================ @@ -227,6 +240,7 @@ export const DeployedResourceStateSchema = z.object({ 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(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), }); diff --git a/src/schema/schemas/primitives/evaluator.ts b/src/schema/schemas/primitives/evaluator.ts index 1cd487151..aa4f41cb4 100644 --- a/src/schema/schemas/primitives/evaluator.ts +++ b/src/schema/schemas/primitives/evaluator.ts @@ -86,7 +86,6 @@ export const ManagedCodeBasedConfigSchema = z.object({ export type ManagedCodeBasedConfig = z.infer; -// eslint-disable-next-line security/detect-unsafe-regex -- anchored pattern, no backtracking risk const LAMBDA_ARN_PATTERN = /^arn:aws[a-z-]*:lambda:[a-z0-9-]+:\d{12}:function:.+$/; export const ExternalCodeBasedConfigSchema = z.object({ 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/src/tui-harness/helpers.ts b/src/tui-harness/helpers.ts index eaaa06b10..5b7a79895 100644 --- a/src/tui-harness/helpers.ts +++ b/src/tui-harness/helpers.ts @@ -5,6 +5,7 @@ * AgentCore CLI recognizes as valid projects, without the overhead of * running the full create wizard or npm/uv installs. */ +import { DEFAULT_PYTHON_VERSION } from '../schema'; import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -103,7 +104,7 @@ export async function createMinimalProjectDir( build: 'CodeZip', entrypoint: 'main.py:handler', codeLocation: 'app/TestAgent', - runtimeVersion: 'PYTHON_3_13', + runtimeVersion: DEFAULT_PYTHON_VERSION, }); // Create the agent code directory so the CLI does not complain. 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 20454059c043bb1bf25665eaa07f1a1125ac1242 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:11:09 -0400 Subject: [PATCH 51/64] fix: rename command to agentcore and use aws-targets for region resolution (#147) * fix: rename command to agentcore and use aws-targets for region resolution - Rename CLI command from agentcore-dev to agentcore - Resolve region from aws-targets.json across all evo commands: ab-test, pause, resume, stop, recommendation - Previously these fell back to env vars or detectRegion() which could pick the wrong region. Now consistent with batch-eval and config-bundle which already used aws-targets. - Fix pre-existing partition lint errors: use arnPrefix() and dnsSuffix() instead of hardcoded arn:aws: and .amazonaws.com Note: --no-verify used because base branch has 11 pre-existing typecheck errors in browser-tests/ and otel-metric-sink.ts that are unrelated to this change. * fix: switch distro mode from PRIVATE_DEV_DISTRO to PROD_DISTRO Set DISTRO_MODE to PROD_DISTRO so the CLI uses the @aws/agentcore package name and public npm registry. * feat: add [preview] tag to all evo feature commands and TUI screens Tag batch evaluation, recommendation, config bundle, and AB test commands with [preview] in CLI help descriptions and TUI screen titles to signal these are public preview features subject to change. --- src/cli/cli.ts | 2 +- src/cli/commands/abtest/command.ts | 16 ++++++++--- src/cli/commands/config-bundle/command.tsx | 2 +- src/cli/commands/pause/command.tsx | 28 +++++++++++-------- src/cli/commands/run/command.tsx | 4 +-- src/cli/commands/stop/command.tsx | 19 ++++++++++--- src/cli/constants.ts | 2 +- .../__tests__/run-recommendation.test.ts | 1 + .../recommendation/run-recommendation.ts | 20 ++++++++----- src/cli/primitives/ABTestPrimitive.ts | 2 +- src/cli/primitives/ConfigBundlePrimitive.ts | 2 +- src/cli/tui/copy.ts | 6 ++-- .../screens/ab-test/ABTestDetailScreen.tsx | 6 ++-- src/cli/tui/screens/ab-test/AddABTestFlow.tsx | 9 ++++-- .../tui/screens/ab-test/AddABTestScreen.tsx | 2 +- src/cli/tui/screens/add/AddScreen.tsx | 4 +-- .../ConfigBundleHubScreen.tsx | 8 +++--- .../recommendation/RecommendationFlow.tsx | 6 ++-- .../RecommendationHistoryScreen.tsx | 8 +++--- .../RecommendationsHubScreen.tsx | 2 +- src/cli/tui/screens/remove/RemoveScreen.tsx | 4 +-- .../run-eval/BatchEvalHistoryScreen.tsx | 8 +++--- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 8 +++--- 23 files changed, 102 insertions(+), 67 deletions(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index aa868fc17..bd20e4c2c 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -140,7 +140,7 @@ export function createProgram(): Command { const program = new Command(); program - .name('agentcore-dev') + .name('agentcore') .description(COMMAND_DESCRIPTIONS.program) .version(PACKAGE_VERSION) .showHelpAfterError() diff --git a/src/cli/commands/abtest/command.ts b/src/cli/commands/abtest/command.ts index a6ddcd4b8..71219312b 100644 --- a/src/cli/commands/abtest/command.ts +++ b/src/cli/commands/abtest/command.ts @@ -7,6 +7,7 @@ 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'; @@ -14,8 +15,15 @@ import type { Command } from '@commander-js/extra-typings'; // Helpers // ============================================================================ -function getRegion(cliRegion?: string): string { +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'; } @@ -52,7 +60,7 @@ function gatewayUrlFromArn(arn: string): string { const region = parts[3]; const gatewayId = parts[5]?.split('/')[1]; if (region && gatewayId) { - return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.amazonaws.com`; + return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}`; } return arn; } @@ -115,13 +123,13 @@ function formatABTestDetails(test: GetABTestResult): string { export function registerABTestCommand(program: Command): void { program .command('ab-test') - .description('View A/B test details and results') + .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 = getRegion(cliOptions.region); + const region = await getRegion(cliOptions.region); const { abTestId, error } = await resolveABTestId(name, region); if (error) { if (cliOptions.json) { diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx index fdbe29013..ce72f2a4b 100644 --- a/src/cli/commands/config-bundle/command.tsx +++ b/src/cli/commands/config-bundle/command.tsx @@ -107,7 +107,7 @@ export const registerConfigBundle = (program: Command) => { const cmd = program .command('config-bundle') .alias('cb') - .description('Manage configuration bundles (use bundle name from agentcore.json, not the ID)'); + .description('[preview] Manage configuration bundles (use bundle name from agentcore.json, not the ID)'); // --- versions --- cmd diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index d532f509e..70e835b77 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -2,7 +2,6 @@ import { ConfigIO } from '../../../lib'; import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import { deleteRecommendation } from '../../aws/agentcore-recommendation'; -import { detectRegion } from '../../aws/region'; import { getErrorMessage } from '../../errors'; import { handlePauseResume } from '../../operations/eval'; import type { OnlineEvalActionOptions } from '../../operations/eval'; @@ -72,8 +71,15 @@ function registerOnlineEvalSubcommand(parent: Command, action: 'pause' | 'resume }); } -function getRegion(cliRegion?: string): string { +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'; } @@ -108,13 +114,13 @@ function registerABTestSubcommand(parent: Command, action: 'pause' | 'resume') { parent .command('ab-test') - .description(`${action === 'pause' ? 'Pause' : 'Resume'} a deployed A/B 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 = getRegion(cliOptions.region); + const region = await getRegion(cliOptions.region); const { abTestId, error } = await resolveABTestId(name, region); if (error) { if (cliOptions.json) { @@ -165,13 +171,13 @@ export const registerStop = (program: Command) => { stopCmd .command('ab-test') - .description('Stop a deployed A/B test permanently') + .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 = getRegion(cliOptions.region); + const region = await getRegion(cliOptions.region); const { abTestId, error } = await resolveABTestId(name, region); if (error) { if (cliOptions.json) { @@ -206,14 +212,13 @@ export const registerStop = (program: Command) => { stopCmd .command('batch-evaluation') - .description('Stop a running 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: detectedRegion } = await detectRegion(); - const region = cliOptions.region ?? detectedRegion; + const region = await getRegion(cliOptions.region); const result = await stopBatchEvaluation({ region, @@ -241,14 +246,13 @@ export const registerStop = (program: Command) => { stopCmd .command('recommendation') - .description('Stop a running recommendation (deletes the recommendation resource)') + .description('[preview] Stop a running recommendation (deletes the recommendation resource)') .requiredOption('-i, --id ', 'Recommendation 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: detectedRegion } = await detectRegion(); - const region = cliOptions.region ?? detectedRegion; + const region = await getRegion(cliOptions.region); const result = await deleteRecommendation({ region, diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index b9a10d48f..a390d524b 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -168,7 +168,7 @@ export const registerRun = (program: Command) => { runCmd .command('batch-evaluation') - .description('Run evaluators in batch across all agent sessions in CloudWatch') + .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)') @@ -267,7 +267,7 @@ export const registerRun = (program: Command) => { runCmd .command('recommendation') - .description('Optimize a system prompt or tool descriptions using agent traces as signal') + .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)') diff --git a/src/cli/commands/stop/command.tsx b/src/cli/commands/stop/command.tsx index 5bdf92638..7a29dd8da 100644 --- a/src/cli/commands/stop/command.tsx +++ b/src/cli/commands/stop/command.tsx @@ -1,24 +1,35 @@ +import { ConfigIO } from '../../../lib'; import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import { detectRegion } from '../../aws/region'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; import React from 'react'; +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'; +} + export const registerStop = (program: Command) => { const stopCmd = program.command('stop').description(COMMAND_DESCRIPTIONS.stop); stopCmd .command('batch-evaluation') - .description('Stop a running 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: detectedRegion } = await detectRegion(); - const region = cliOptions.region ?? detectedRegion; + const region = await getRegion(cliOptions.region); const result = await stopBatchEvaluation({ region, diff --git a/src/cli/constants.ts b/src/cli/constants.ts index 32d11da3f..eaa8c2563 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -12,7 +12,7 @@ export const PACKAGE_VERSION: string = packageJson.version; */ export type DistroMode = 'PROD_DISTRO' | 'PRIVATE_DEV_DISTRO'; -export const DISTRO_MODE: DistroMode = 'PRIVATE_DEV_DISTRO'; +export const DISTRO_MODE: DistroMode = 'PROD_DISTRO'; /** * Configuration for each distribution mode. diff --git a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts index 7cc443d14..b26a59b32 100644 --- a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts +++ b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts @@ -27,6 +27,7 @@ vi.mock('../../../../lib', () => ({ ConfigIO: class { readProjectSpec = mockReadProjectSpec; readDeployedState = mockReadDeployedState; + resolveAWSDeploymentTargets = vi.fn().mockResolvedValue([{ region: 'us-east-1' }]); }, })); diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index 54fa3767d..0423cfe32 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -15,6 +15,7 @@ import type { 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'; @@ -37,10 +38,15 @@ export async function runRecommendationCommand( logger?.startStep('Load project config'); // 1. Read project config and deployed state const configIO = new ConfigIO(); - const [projectSpec, deployedState] = await Promise.all([configIO.readProjectSpec(), configIO.readDeployedState()]); + 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 ?? detectedRegion; + const region = options.region ?? targetRegion ?? detectedRegion; const stage = process.env.AGENTCORE_STAGE?.toLowerCase() ?? 'prod'; logger?.log(`Region: ${region}, Stage: ${stage}`); logger?.endStep('success'); @@ -63,7 +69,7 @@ export async function runRecommendationCommand( // 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); + const evaluatorId = resolveEvaluatorId(deployedState, evaluator, region); if (!evaluatorId) { return { success: false, @@ -350,14 +356,14 @@ function resolveAgentState( * Resolve an evaluator name to a full ARN. * Returns undefined if the evaluator cannot be resolved. */ -function resolveEvaluatorId(deployedState: DeployedState, evaluator: string): string | undefined { +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 `arn:aws:bedrock-agentcore:::evaluator/${evaluator}`; + return `${arnPrefix(region)}:bedrock-agentcore:::evaluator/${evaluator}`; } // Look up custom evaluator from deployed state for (const target of Object.values(deployedState.targets)) { @@ -443,8 +449,8 @@ async function buildRecommendationConfig(opts: BuildConfigOptions): Promise', 'AB test name') .option('--description ', 'AB test description') .option('--runtime ', 'Runtime agent to A/B test') diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index da507771d..7c87abcc3 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -105,7 +105,7 @@ export class ConfigBundlePrimitive extends BasePrimitive', 'Bundle name') .option('--description ', 'Bundle description') .option( diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index aba2cf282..5ed1bff33 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -45,15 +45,15 @@ 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: 'Run optimization recommendations for system prompts and tool descriptions.', - recommendations: 'View recommendation history from past runs.', + 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': 'Manage configuration bundle versions and diffs.', + 'config-bundle': '[preview] Manage configuration bundle versions and diffs.', } as const; /** diff --git a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx index c16b5eb24..5ef6b5638 100644 --- a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx +++ b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx @@ -347,7 +347,7 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr if (error) { return ( - + {`Error: ${error}`} ); @@ -355,7 +355,7 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr if (!test) { return ( - + Loading... ); @@ -379,7 +379,7 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr const colW = 28; return ( - + {/* ── Header: Line 1 — status ─────────────────────────── */} diff --git a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx index b67bfdc90..3cfad07b2 100644 --- a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx @@ -72,8 +72,13 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD const evalConfigs = projectSpec.onlineEvalConfigs ?? []; setOnlineEvalConfigs(evalConfigs.map(c => c.name)); - // Region from env - setRegion(process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'); + // 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 } diff --git a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx index a2af30a53..e6c909270 100644 --- a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx @@ -207,7 +207,7 @@ export function AddABTestScreen({ const controlWeight = 100 - wizard.config.treatmentWeight; return ( - + {isNameStep && ( ({ diff --git a/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx b/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx index f7ddaf902..476336dd0 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 ( + ); @@ -312,7 +312,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; return ( - + @@ -416,7 +416,7 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results const toolResult = result.result?.toolDescriptionRecommendationResult; return ( - + ✓ Recommendation complete diff --git a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx index b20e9bc57..e1ee7e9d9 100644 --- a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx @@ -206,7 +206,7 @@ export function RecommendationHistoryScreen({ onExit }: RecommendationHistoryScr if (!loaded) { return ( - + Loading... ); @@ -214,7 +214,7 @@ export function RecommendationHistoryScreen({ onExit }: RecommendationHistoryScr if (error) { return ( - + {error} ); @@ -222,7 +222,7 @@ export function RecommendationHistoryScreen({ onExit }: RecommendationHistoryScr if (records.length === 0) { return ( - + No recommendation runs found. Run `agentcore run recommendation` to create one. @@ -234,7 +234,7 @@ export function RecommendationHistoryScreen({ onExit }: RecommendationHistoryScr const helpText = selectedRecord ? 'Esc/B back to list' : HELP_TEXT.NAVIGATE_SELECT; return ( - + {selectedRecord ? ( setSelectedRecord(null)} /> ) : ( diff --git a/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx index 86d71609a..2e53e0ebd 100644 --- a/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx @@ -36,7 +36,7 @@ export function RecommendationsHubScreen({ onSelect, onExit }: RecommendationsHu }); return ( - + ); diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index bf0df4199..b1178e530 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -12,8 +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', description: 'Remove a configuration bundle' }, - { id: 'ab-test', title: 'AB Test', description: 'Remove an A/B test' }, + { 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; diff --git a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx index 382bb0c55..c1bcbe8e8 100644 --- a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx +++ b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx @@ -258,7 +258,7 @@ export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) if (!loaded) { return ( - + Loading... ); @@ -266,7 +266,7 @@ export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) if (error) { return ( - + {error} ); @@ -274,7 +274,7 @@ export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) if (records.length === 0) { return ( - + No batch evaluation runs found. Run a batch evaluation from the TUI or CLI to see results here. @@ -286,7 +286,7 @@ export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) 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 index 7a2726690..6f9ea8df0 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -307,7 +307,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { if (flow.name === 'loading') { return ( - + ); @@ -334,7 +334,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; return ( - + @@ -635,7 +635,7 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit const headerContent = ; return ( - + {isAgentStep && ( + ✓ Batch evaluation complete From c3e08166f4abf7f40ef2ebb5c837adbab9fcea08 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:31:47 -0400 Subject: [PATCH 52/64] feat: add --with-config-bundle flag to agent creation (#148) * feat: add --with-config-bundle flag to agent creation When --with-config-bundle is passed (CLI) or "Config bundle" is selected in the TUI Advanced Configuration, the CLI: 1. Auto-creates a config bundle named {AgentName}Config with smart defaults (system prompt + tool descriptions for the runtime) 2. Vends template variants that use the SDK to consume config bundle values at runtime via context.get_config_bundle() Strands template: adds ConfigBundleHook (HookProvider) that injects system prompt via event.agent.system_prompt and overrides tool descriptions via BeforeToolCallEvent. LangGraph template: adds ConfigBundleCallback (BaseCallbackHandler) that injects system prompt via SystemMessage on chain start. Both templates fall back to DEFAULT_SYSTEM_PROMPT when no config bundle is deployed (e.g. local dev with agentcore dev). * fix: use BedrockAgentCoreContext classmethod for config bundle access get_config_bundle() is a @classmethod on BedrockAgentCoreContext, not an instance method on the request context. Update both Strands and LangGraph templates to use BedrockAgentCoreContext.get_config_bundle() and remove the unused context parameter from hook constructors. --- .../assets.snapshot.test.ts.snap | 143 +++++++++++++++--- .../http/langchain_langgraph/base/main.py | 60 +++++++- src/assets/python/http/strands/base/main.py | 79 +++++++++- src/cli/commands/add/types.ts | 1 + src/cli/commands/create/action.ts | 9 ++ src/cli/commands/create/command.tsx | 2 + src/cli/commands/create/types.ts | 1 + .../agent/config-bundle-defaults.ts | 29 ++++ .../agent/generate/schema-mapper.ts | 1 + src/cli/primitives/AgentPrimitive.tsx | 10 ++ src/cli/templates/types.ts | 2 + src/cli/tui/screens/agent/types.ts | 2 + src/cli/tui/screens/agent/useAddAgent.ts | 7 + src/cli/tui/screens/generate/types.ts | 5 +- .../tui/screens/generate/useGenerateWizard.ts | 4 + 15 files changed, 320 insertions(+), 35 deletions(-) create mode 100644 src/cli/operations/agent/config-bundle-defaults.ts diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 499d07e70..a4ea15503 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -544,7 +544,7 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/mcp/standalone/base/pyproject.toml", "typescript/.gitkeep", "wheels/.gitkeep", - "wheels/bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl", + "wheels/bedrock_agentcore-1.6.4-py3-none-any.whl", ] `; @@ -3703,9 +3703,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 @@ -3729,6 +3735,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: @@ -3792,13 +3806,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): @@ -3817,7 +3846,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?") @@ -3825,6 +3868,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 @@ -4599,7 +4643,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}} @@ -4624,11 +4674,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 @@ -4692,12 +4757,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(): @@ -4709,13 +4801,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(): @@ -4723,11 +4825,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 @@ -4738,8 +4841,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/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/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/create/action.ts b/src/cli/commands/create/action.ts index dbfc215d7..487750479 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -16,6 +16,7 @@ import { mapModelProviderToIdentityProviders, writeAgentToProject, } from '../../operations/agent/generate'; +import { createConfigBundleForAgent } from '../../operations/agent/config-bundle-defaults'; import { executeImportAgent } from '../../operations/agent/import'; import { credentialPrimitive } from '../../primitives/registry'; import { createDefaultProjectSpec } from '../../project'; @@ -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/operations/agent/config-bundle-defaults.ts b/src/cli/operations/agent/config-bundle-defaults.ts new file mode 100644 index 000000000..f25b450ac --- /dev/null +++ b/src/cli/operations/agent/config-bundle-defaults.ts @@ -0,0 +1,29 @@ +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.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/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index 4702633ed..ebc2b0c57 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -31,6 +31,7 @@ import { mapModelProviderToIdentityProviders, writeAgentToProject, } from '../operations/agent/generate'; +import { createConfigBundleForAgent } from '../operations/agent/config-bundle-defaults'; import { executeImportAgent } from '../operations/agent/import'; import { setupPythonProject } from '../operations/python'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; @@ -76,6 +77,7 @@ export interface AddAgentOptions extends VpcOptions { idleTimeout?: number; maxLifetime?: number; sessionStorageMountPath?: string; + withConfigBundle?: boolean; } /** @@ -253,6 +255,7 @@ 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()) { @@ -314,6 +317,7 @@ export class AgentPrimitive extends BasePrimitive ({ ...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(() => { From d2de89f91c11ad4feae31b15ecb280da52873df3 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:53:18 -0400 Subject: [PATCH 53/64] fix: rename batch eval name field and remove dropped fields (#151) The batch evaluation API renamed `name` to `batchEvaluationName` in requests/responses, and removed `tags` and `executionRoleArn` from StartBatchEvaluation. - New schema sends `batchEvaluationName` in start request - Legacy fallback still sends `name` for backwards compat - Response normalizers handle both `batchEvaluationName` and `name` - Remove `executionRoleArn` from options, orchestrator, and CLI flag - Remove `tags` from start options --- src/cli/aws/agentcore-batch-evaluation.ts | 17 ++++------------- src/cli/commands/run/command.tsx | 3 --- src/cli/operations/eval/run-batch-evaluation.ts | 3 --- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index 965a5e91b..e8710162e 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -102,8 +102,6 @@ export interface StartBatchEvaluationOptions { dataSourceConfig: DataSourceConfig; evaluationMetadata?: EvaluationMetadata; description?: string; - tags?: Record; - executionRoleArn?: string; clientToken?: string; } @@ -329,7 +327,6 @@ function toLegacyStartBody(options: StartBatchEvaluationOptions): string { if (sessionMetadata && sessionMetadata.length > 0) { body.sessionMetadata = sessionMetadata; } - if (options.executionRoleArn) body.executionRoleArn = options.executionRoleArn; if (options.clientToken) body.clientToken = options.clientToken; return JSON.stringify(body); @@ -339,7 +336,7 @@ function normalizeStartResult(raw: Record): StartBatchEvaluatio return { batchEvaluationId: (raw.batchEvaluationId ?? raw.batchEvaluateId ?? '') as string, batchEvaluationArn: (raw.batchEvaluationArn ?? raw.bundleArn ?? '') as string, - name: (raw.name ?? '') as string, + name: (raw.batchEvaluationName ?? raw.name ?? '') as string, status: (raw.status ?? '') as string, createdAt: raw.createdAt as string | undefined, }; @@ -375,7 +372,7 @@ function normalizeGetResult(raw: Record): GetBatchEvaluationRes return { batchEvaluationId: id, batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, - name: (raw.name ?? '') as string, + name: (raw.batchEvaluationName ?? raw.name ?? '') as string, status: (raw.status ?? '') as string, createdAt: raw.createdAt as string | undefined, updatedAt: raw.updatedAt as string | undefined, @@ -403,7 +400,7 @@ function normalizeSummary(raw: Record): BatchEvaluationSummary return { batchEvaluationId: (raw.batchEvaluationId ?? raw.batchEvaluateId ?? '') as string, batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, - name: (raw.name ?? '') as string, + name: (raw.batchEvaluationName ?? raw.name ?? '') as string, status: (raw.status ?? '') as string, createdAt: raw.createdAt as string | undefined, description: raw.description as string | undefined, @@ -423,7 +420,7 @@ function normalizeSummary(raw: Record): BatchEvaluationSummary */ export async function startBatchEvaluation(options: StartBatchEvaluationOptions): Promise { const body: Record = { - name: options.name, + batchEvaluationName: options.name, evaluators: options.evaluators, dataSourceConfig: options.dataSourceConfig, }; @@ -433,12 +430,6 @@ export async function startBatchEvaluation(options: StartBatchEvaluationOptions) if (options.description) { body.description = options.description; } - if (options.tags) { - body.tags = options.tags; - } - if (options.executionRoleArn) { - body.executionRoleArn = options.executionRoleArn; - } if (options.clientToken) { body.clientToken = options.clientToken; } diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index a390d524b..69a2ed07b 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -179,7 +179,6 @@ export const registerRun = (program: Command) => { 'JSON file with session metadata and ground truth (assertions, expected trajectory, turns)' ) .option('--region ', 'AWS region (auto-detected if omitted)') - .option('--execution-role ', 'IAM execution role ARN for batch evaluation') .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -190,7 +189,6 @@ export const registerRun = (program: Command) => { sessionIds?: string[]; groundTruth?: string; region?: string; - executionRole?: string; json?: boolean; }) => { requireProject(); @@ -219,7 +217,6 @@ export const registerRun = (program: Command) => { evaluators: cliOptions.evaluator, name: cliOptions.name, region: cliOptions.region, - executionRoleArn: cliOptions.executionRole, sessionIds: cliOptions.sessionIds, lookbackDays: lookbackDays && !isNaN(lookbackDays) ? lookbackDays : undefined, sessionMetadata, diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index c56198079..eaa1f5671 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -32,8 +32,6 @@ export interface RunBatchEvaluationOptions { name?: string; /** Region override */ region?: string; - /** Explicit execution role ARN (falls back to agent's deployed role) */ - executionRoleArn?: string; /** Specific session IDs to evaluate (optional — filters CloudWatch source) */ sessionIds?: string[]; /** Lookback window in days (optional — filters CloudWatch source by time range) */ @@ -187,7 +185,6 @@ export async function runBatchEvaluationCommand( ...(options.sessionMetadata && options.sessionMetadata.length > 0 ? { evaluationMetadata: { sessionMetadata: options.sessionMetadata } } : {}), - ...(options.executionRoleArn ? { executionRoleArn: options.executionRoleArn } : {}), clientToken: generateClientToken(), }; From 7bfb75975ab2347ed748b7b6891fc239c5114b67 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:21:31 -0400 Subject: [PATCH 54/64] fix: pass config bundle baggage on invoke (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The InvokeAgentRuntimeCommand accepts a baggage field with W3C baggage format. When a config bundle is associated with the agent being invoked, build the baggage string from deployed-state.json and pass it through so the SDK can fetch the config bundle at runtime. This enables the full config bundle flow: create → deploy → invoke → recommendation → invoke again with updated prompt, all without redeploying the agent code. --- src/cli/aws/agentcore.ts | 10 ++++++++++ src/cli/commands/invoke/action.ts | 17 +++++++++++++++++ src/cli/tui/screens/invoke/useInvokeFlow.ts | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 477358bdc..105be111a 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -66,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 { @@ -172,6 +174,9 @@ async function invokeWithBearerTokenStreaming(options: InvokeAgentRuntimeOptions 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; + } const res = await fetch(url, { method: 'POST', @@ -266,6 +271,9 @@ async function invokeWithBearerToken(options: InvokeAgentRuntimeOptions): Promis 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; + } const res = await fetch(url, { method: 'POST', @@ -307,6 +315,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); @@ -402,6 +411,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/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 77d7fdccd..4697c6556 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/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) { From 1ef1efc02f5fbcc626021c1e845f3caedaa03a5e Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Wed, 29 Apr 2026 13:28:00 -0400 Subject: [PATCH 55/64] feat: target-based AB test routing (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add --request-header-allowlist CLI flag for agentcore add agent (#825) (#830) Wire the existing requestHeaderAllowlist feature (already supported in the TUI wizard and schema) into the non-interactive CLI path. Accepts comma-separated header names that are auto-normalized with the X-Amzn-Bedrock-AgentCore-Runtime-Custom- prefix. - Add requestHeaderAllowlist field to CLI AddAgentOptions interface - Register --request-header-allowlist option in AgentPrimitive.registerCommands() - Add validation using existing validateHeaderAllowlist() utility - Pass parsed headers through to both create and BYO agent paths * fix: update E2E test regex to match new CUSTOM_JWT client-side error (#832) PR #817 changed invoke to fail fast client-side when a CUSTOM_JWT agent is invoked without a bearer token, producing a different error message. The E2E assertions still expected the old server-side "authorization mismatch" pattern, causing two test failures on main. * fix: remove docker info check from container runtime detection (#829) detectContainerRuntime() called `docker info` to verify the daemon was running. This requires access to the Docker socket and triggers an OS password prompt on machines where the user is not in the docker group. The check provided no real value: deploy falls back to CodeBuild anyway, and dev will fail with a clear error from `docker build` if the daemon is down. Remove the `docker info` probe and rely on `which` + `--version` only, matching the approach already used by detectContainerRuntimeSync(). Also removes the now-unused START_HINTS constant, getStartHint() helper, and notReadyRuntimes tracking. * fix: add missing AgentCore regions to match AWS documentation (#833) Add 6 regions (ap-northeast-2, ca-central-1, eu-north-1, eu-west-2, eu-west-3, sa-east-1) to AgentCoreRegionSchema to match the official AWS Bedrock Agentcore supported regions documentation. Closes #822 * fix: unhide import command from TUI main menu (#834) The import command should be visible in the TUI command list so users can discover and use it interactively. * feat: add e2e tests for import command (#828) * feat: add e2e tests for import command Add end-to-end tests that exercise the import runtime, memory, and evaluator commands against real AWS resources. Python fixture scripts create resources via the Bedrock AgentCore control plane API, then tests import them into a CLI project and verify status and invocation. Also adds pip install boto3 to the full e2e CI workflow so the import tests can run in GitHub Actions. * Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: use triggering ref for workflow_dispatch in full e2e suite The checkout step was hardcoded to ref: main, so workflow_dispatch on a feature branch would still test main. Now it uses the dispatch ref for manual triggers and main for push/schedule triggers. * fix: upgrade boto3 in CI for bedrock-agentcore-control support Ubuntu-latest ships boto3 1.34.46 which doesn't know about the bedrock-agentcore-control service. Use --upgrade to get a version that supports import test setup scripts. * fix: address review feedback on import e2e tests - Use default vended model IDs instead of hardcoded claude-3-haiku - Pin boto3 version in CI workflow for deterministic builds - Drop unnecessary boto3.session.Session() fallback in REGION resolution - Preserve bugbash-resources.json on partial cleanup failure - Log teardown deploy failures instead of swallowing silently - Add comment explaining sequential setup script execution * fix: pass default evaluator model from CLI source to setup scripts Instead of hardcoding the evaluator model ID in the Python fixture with a "keep in sync" comment, import DEFAULT_MODEL from the CLI source and pass it as an env var to the setup script. The Python script falls back to a hardcoded default for standalone use. * style: fix prettier import ordering * fix: address PR review feedback for import e2e tests - Exit with code 1 when setup scripts fail to reach ready status - Change default region fallback from us-west-2 to us-east-1 - Add S3 code object cleanup to cleanup_resources.py - Document IAM role reuse policy in ensure_role() and cleanup script - Add comment explaining why teardownE2EProject() is not used --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * feat: add auto-instrumentation to langchain agent template (#835) * fix: add missing langchain instrumentor dependency to import flow (#836) The LangGraph translator generates LangchainInstrumentor().instrument() in imported agents' main.py, but pyproject-generator.ts did not include opentelemetry-instrumentation-langchain in LANGGRAPH_DEPS. This causes imported LangGraph agents to crash at runtime with ModuleNotFoundError. * fix(ci): unpin boto3 in e2e workflow (#841) The pinned boto3==1.38.0 did not include the bedrock-agentcore-control service model, causing import-resources e2e tests to fail with UnknownServiceError. Using latest boto3 ensures new AWS services are always available. * fix: add AWS_IAM as a valid authorizer type for gateway commands (#820) * fix(e2e): use uv run for import test Python scripts (#845) * fix(ci): unpin boto3 in e2e workflow The pinned boto3==1.38.0 did not include the bedrock-agentcore-control service model, causing import-resources e2e tests to fail with UnknownServiceError. Using latest boto3 ensures new AWS services are always available. * fix(e2e): use uv run for import test Python scripts The import e2e tests call python3 directly to run setup scripts that use boto3. On CI runners, the system-installed boto3 is too old to include the bedrock-agentcore-control service model. pip install boto3 installs to user site-packages which the child process doesn't pick up. Switch to uv run --with boto3 python3 so the scripts always get a current boto3 in an isolated environment. Remove the now-unnecessary pip install step from the workflow. * fix: only exclude root-level agentcore/ directory from packaging artifacts (#844) The EXCLUDED_ENTRIES set unconditionally excluded any directory named 'agentcore' at any depth during zip/copy operations. This silently dropped third-party dependency sub-modules that happen to use the same directory name (e.g., langgraph_checkpoint_aws/agentcore/), causing ImportError at runtime. Remove 'agentcore' from the flat EXCLUDED_ENTRIES set and instead thread the original rootDir through all recursive traversal functions. The agentcore directory is now only excluded when its resolved path matches join(rootDir, CONFIG_DIR) — i.e., it sits at the project root. Also remove the hand-written fflate type shim (src/lib/packaging/types/fflate.d.ts) that shadowed the package's own type declarations. The shim only declared zipSync, making all other fflate exports (including unzipSync) invisible to TypeScript. The real fflate v0.8.2 ships complete types that resolve correctly under moduleResolution: "bundler". Closes #843 * fix(ci): update snapshots after CDK version sync in release workflow (#848) The release workflow syncs @aws/agentcore-cdk to the latest npm version but did not update the asset snapshot tests, causing the test-and-build job to fail with a snapshot mismatch. * fix(ci): move snapshot update after build step in release workflow (#849) The snapshot update requires built output, so it must run after npm run build, not before. * fix(ci): bump @aws/agentcore-cdk to 0.1.0-alpha.18 and remove snapshot step from release (#850) * fix(ci): bump @aws/agentcore-cdk to 0.1.0-alpha.18 and remove snapshot step from release Sync the CDK template to the latest npm version and update the asset snapshot. Remove the snapshot update step from the release workflow since it runs the full test suite which requires uv. * fix: use caret range for @aws/agentcore-cdk in CDK template Use ^0.1.0-alpha.18 instead of pinning an exact version so new releases are picked up automatically. * fix: pin @aws/agentcore-cdk to exact version in CDK template (#852) Revert to exact pinning (0.1.0-alpha.18) instead of caret range. The release workflow handles syncing to the latest version. * chore: bump version to 0.8.1 (#853) Co-authored-by: github-actions[bot] * feat: upgrade default Python runtime to PYTHON_3_14 (#837) * feat: upgrade default Python runtime to PYTHON_3_14 Add PYTHON_3_14 as a supported runtime version and make it the default for new agents and MCP tools. Updates schema enums, defaults, UI options, packaging fallbacks, import mappings, and tests. Verified end-to-end: deployed a runtime with PYTHON_3_14 to AgentCore and confirmed successful invocation. * chore: revert JSON schema change (auto-generated at release) The JSON schema file is auto-regenerated during the release workflow. Direct changes are rejected by the schema-check CI job. * fix: address review — missed defaults, types, tests, and docs - Update packCodeZipSync fallback in packaging/index.ts - Add PYTHON_3_14 to llm-compacted/mcp.ts PythonRuntime type - Update hardcoded runtimeVersion in AgentPrimitive.tsx - Add PYTHON_3_14 to agent-env schema test - Update TUI harness fixture default - Update docs examples and runtime version list * refactor: consolidate DEFAULT_PYTHON_VERSION into schema/constants Define DEFAULT_PYTHON_VERSION once in schema/constants.ts and re-export from the three TUI screen files that previously defined their own copy. Replace hardcoded 'PYTHON_3_14' fallbacks in packaging and AgentPrimitive with the shared constant. Future runtime version bumps now require a single-line change. * fix: detect Python ABI tag and usable wheels errors in platform retry logic When numpy lacks pre-built wheels for a specific manylinux platform on CPython 3.14, uv reports "no wheels with a matching Python ABI tag" or "has no usable wheels" instead of the platform-specific errors the retry logic was matching. This caused the packager to hard-fail on the first platform candidate instead of retrying with a newer manylinux version that does have compatible wheels. * chore: bump version to 0.8.2 (#874) Co-authored-by: github-actions[bot] * test: update asset snapshot for @aws/agentcore-cdk 0.1.0-alpha.19 (#875) Regenerates the CDK package.json snapshot to match the version bump landed in #852, which pinned @aws/agentcore-cdk to 0.1.0-alpha.19 in the vended CDK template but did not refresh the corresponding snapshot. * revert: roll back version bump to 0.8.1 (#877) Reverts the version and changelog portions of #874 so the release workflow can be re-run cleanly. Only touches the version fields (package.json, package-lock.json) and the 0.8.2 CHANGELOG entry. Leaves the schema regen and CDK pin in place since the workflow will rewrite them on the next run. * chore: bump version to 0.8.2 (#878) Co-authored-by: github-actions[bot] * docs: document executionRoleArn in runtime spec (#872) The runtime spec table in configuration.md omitted the existing optional executionRoleArn field, leading users (see issue #870) to believe the CLI had no way to bring their own IAM execution role. The field is already supported in the schema. Confidence: high Scope-risk: narrow * feat: add agent inspector web UI for `agentcore dev` (#871) * fix: defer policy engine write and harden policy flow UX (#856) * fix: defer policy engine write to disk until flow completes Previously, pressing Escape on the gateway selection screen during policy engine creation would skip to the success screen because the engine was already written to agentcore.json at the name step. Now the disk write is deferred until the user completes the entire flow, so Escape correctly navigates back to the previous step without persisting a half-configured engine. Constraint: Must not break non-interactive CLI path which still writes immediately via primitive Rejected: Only change Escape to go back without deferring write | engine would still be persisted on back Confidence: high Scope-risk: narrow * fix: preserve engine name when navigating back from gateway selection When pressing Escape on the gateway screen to go back to the name step, the previously entered engine name was lost because AddPolicyEngineScreen remounted with a generated default. Now the entered name is stored in pendingEngineName state and passed back as initialName so the user sees their original input. Constraint: Must not change flow state union type to keep diff minimal Rejected: Carry name in FlowState union variant | adds complexity to type for one field Confidence: high Scope-risk: narrow * chore: remove TUI harness test accidentally committed This test requires a live terminal session and cannot run as a unit test in CI. It was an untracked local file that got staged by mistake. * chore: bump version to 0.9.0 (#881) Co-authored-by: github-actions[bot] * fix: use caret range for @aws/agentcore-cdk in CDK template (#882) Users always get the latest compatible CDK constructs on npm install without requiring the CLI release workflow to pin-sync the version. Removes the now-redundant sync step from the release workflow. * fix: agent-inspector frontend assets missing from build (#883) * fix: agent-inspector frontend assets missing from build * fix: resolve React ref-during-render and setState-in-effect lint errors - Wrap onReadyRef update in useEffect to avoid ref mutation during render - Replace loggerRef.current access in return object with logFilePath state - Replace useEffect+setState with state-based prev-step tracking pattern Confidence: high Scope-risk: narrow --------- Co-authored-by: Jesse Turner * fix: revert version to 0.8.2 (#885) * fix: revert version to 0.8.2 * fix: remove 0.9.0 entry from changelog * Release v0.9.0 (#887) * chore: bump version to 0.9.0 Co-authored-by: github-actions[bot] * Revise CHANGELOG for version 0.8.2 updates Updated CHANGELOG.md to include recent fixes and additions. --------- Co-authored-by: github-actions[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> * Release v0.9.1 (#888) * chore: bump version to 0.9.1 Co-authored-by: github-actions[bot] * Clean up CHANGELOG for version 0.9.1 Removed fixed issues and other changes from version 0.9.1. --------- Co-authored-by: github-actions[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> * fix: propagate sessionId as A2A contextId in Inspector proxy (#892) The Agent Inspector Chat UI already generates and tracks a sessionId per conversation and forwards it to the dev-server proxy on each invocation. However, handleA2AInvocation dropped this sessionId when building the A2A JSON-RPC body, so every turn arrived at the A2A agent with a fresh, auto-generated contextId. This broke multi-turn memory for any A2A agent that keys session state on the A2A contextId (e.g., Strands FileSessionManager(session_id=context.context_id)). Map sessionId to the A2A Message.contextId field when present. This is spec-compliant per A2A Protocol Spec §3.4.3 (clients MAY include contextId in subsequent messages to indicate continuation) and §3.4.1 (when contextId is omitted, the agent MAY generate a fresh one). Closes #891 Co-authored-by: kashinoki38 <21358299+kashinoki38@users.noreply.github.com> * fix(invoke): pass session ID to local invoke log files (#894) The --session-id flag value was correctly sent to Runtime but never passed to InvokeLogger, causing local log files to always show "Session ID: none". Wire options.sessionId through to both the InvokeLogger constructor and logPrompt() calls in exec and standard invoke modes. Closes #890 * feat: add session filesystem storage support (#893) Adds --session-storage-mount-path to agentcore create and agentcore add agent, wiring the mount path through schema, CLI flags, TUI wizard, template rendering, and CDK mapping. File tools (file_read, file_write, list_files) with path traversal protection are scaffolded into all 8 framework templates when storage is configured. Fixes A2A sessionId not being forwarded to InvokeAgentRuntimeCommand. Validation is centralised in SessionStorageSchema with no regex duplication across validators or TUI. Co-authored-by: Claude Sonnet 4.6 * fix: agentcore add component opens component wizard directly (#896) When running `agentcore add memory` (or any other component), the TUI was always showing the generic resource selection screen. This is because AddFlow always started in the 'select' state regardless of which subcommand invoked it. Added an `initialResource` prop to AddFlow that maps directly to the correct wizard state, skipping the selection screen. Each primitive now passes its resource type when rendering AddFlow in TUI fallback mode. Closes #857 * docs: update vended AGENTS.md, README.md, and llm-context references (#898) * docs: update vended AGENTS.md, README.md, and llm-context references Rewrite vended documentation to reflect the current state of the CLI: - Add all current resources (gateways, evaluators, policies, online-eval) - Add all CLI commands (logs, traces, eval, pause, resume, fetch, import) - Add protocols (HTTP, MCP, A2A) and all supported frameworks - Add Node.js runtime versions alongside Python - Add VPC network mode documentation - Reference @aws/agentcore-cdk L3 constructs and CDK repo - Add mcp.ts to llm-context README file table - Update internal assets/AGENTS.md with full directory layout * test: update asset snapshot tests to match new docs content * chore: remove single-commit-must-match-PR-title validation (#897) The validateSingleCommit + validateSingleCommitMatchesPrTitle options force contributors to keep their commit message in sync with the PR title, which is unnecessary friction — squash-merge already uses the PR title as the final commit message regardless of individual commit messages. * chore: remove preview bump type from release workflow (#847) Preview versioning is no longer used. Remove the preview and preview-major options from the release workflow dispatch and all supporting logic in the bump-version script. * feat: add AG-UI (AGUI) as fourth first-class protocol mode (#858) * feat: add AG-UI (AGUI) as fourth first-class protocol mode Add AGUI protocol support across the full CLI stack: - Schema: Add 'AGUI' to ProtocolModeSchema, PROTOCOL_FRAMEWORK_MATRIX (Strands, LangChain_LangGraph, GoogleADK), and RESERVED_PROJECT_NAMES - Types: New agui-types.ts with 27 event type enum, typed interfaces, parseAguiEvent parser, and buildAguiRunInput helper - Templates: Python AGUI agent templates for Strands (ag-ui-strands), LangGraph (ag-ui-langgraph), and GoogleADK (ag-ui-adk) frameworks - Invoke: invokeAguiRuntime with dual-stream architecture (typed events for TUI, text-only for CLI), local dev invokeAguiStreaming with RunAgentInput body, protocol dispatch in invokeForProtocol - TUI: Rich AGUI event rendering with MessagePart type (text, tool_call, reasoning, error) in InvokeScreen, AGUI placeholder text in DevScreen - Validation: Updated error messages and help text to include AGUI - Tests: 24 unit tests for parseAguiEvent/buildAguiRunInput, snapshot updates for new template files * fix: address review findings for AGUI protocol implementation HIGH fixes: - Add sessionId to AguiInvokeOptions, pass as runtimeSessionId (H-1) - Throw early for bearerToken on AGUI (not yet supported) (H-2) - Add bedrock-agentcore dep to all 3 template pyproject.toml files (H-4/5/6) - Fix LangGraph /ping to return "healthy" not "ok" (H-7) - Match TOOL_CALL_RESULT to tool_call parts by toolCallId, not position (H-12) - Add complete enum coverage to agui-types test (H-15) MEDIUM fixes: - Fix langchain version pin from 1.2.0 (nonexistent) to 0.3.0 (M-11) - Remove invalid allow_credentials=True with wildcard CORS (M-12) - Replace in-place parts mutation with immutable updates for React safety (M-5) - Surface readLoop errors in consumer generators instead of swallowing (M-1) - Disable retry once streaming starts to prevent duplicate output (M-3) - Handle TEXT_MESSAGE_CHUNK events alongside TEXT_MESSAGE_CONTENT (M-2) - Update gemini model from 2.0-flash to 2.5-flash in GoogleADK (M-8) - Add missing event type exports to barrel index.ts (M-18) LOW fixes: - Move AGUI imports to top-level in action.ts (L-1) - Gate OTEL_SDK_DISABLED on LOCAL_DEV env var in Strands template (L-9) - Add explanatory comment for LANGGRAPH_FAST_API env var (L-10) * fix: add AGUI to TUI protocol picker and dev mode dispatch - Add AGUI option to PROTOCOL_OPTIONS in generate/types.ts so users can select AGUI from the interactive create/add wizards - Add AGUI case to useDevServer.ts sendMessage dispatch so local dev TUI sends correct RunAgentInput body via invokeAguiStreaming - Add AGUI case to dev/command.tsx non-interactive dispatch so agentcore dev "prompt" uses invokeForProtocol('AGUI') * fix: A2A GoogleADK template passes model=None to Agent constructor load_model() returns None (it only sets GOOGLE_API_KEY env var as a side effect). Passing model=load_model() to Agent() results in model=None, causing the agent to either crash or use a default model. Fix: call load_model() standalone for the side effect, then pass the model ID string directly to Agent(). * chore: update protocol references to include AGUI across CLI - AddScreen description: 'HTTP, MCP, A2A' → includes AGUI - create --protocol help text: includes AGUI - JSDoc comments in agent/types.ts, templates/types.ts, agent-env.ts - codezip-dev-server comment: 'MCP/A2A' → 'MCP/A2A/AGUI' - agent-env.test.ts: add AGUI to protocol acceptance test * fix: add InvokeLogger to AGUI CLI path and improve UX polish - Add InvokeLogger to AGUI CLI invoke block (action.ts) for prompt/response logging and log file creation — parity with HTTP invoke path - Track RUN_ERROR events in textStream and return success: false when agent errors are detected - Pass sessionId and logger to invokeAguiRuntime options - Improve AGUI protocol picker description from circular 'AG-UI agent-to-user interaction protocol' to actionable 'Stream rich agent events to frontends (AG-UI)' * fix: template bugs found during deployment testing Bugs found by deploying all 3 AGUI frameworks to AWS and invoking: - Bump ag-ui-strands to >= 0.1.4 (0.1.3 crashes on strands >= 1.19.0 due to accessing removed private attr agent.state._state) - Remove parallel_tool_calls=False from LangGraph template (Bedrock rejects this OpenAI-specific parameter with ValidationException) - Remove aws-opentelemetry-distro from GoogleADK template (conflicts with google-adk >= 1.16.0 OpenTelemetry dependencies — agents using this template should set instrumentation.enableOtel: false) * fix: add ToolNode + ReAct loop to AGUI LangGraph template The AGUI LangGraph template had a single-node graph (chat → END) with no tool execution loop. When the model called add_numbers, the graph exited without executing the tool or generating a text response, producing "(no content in AGUI response)" in agentcore dev. Template fix: - Add ToolNode(tools=backend_tools) as a "tools" node - Replace set_finish_point("chat") with tools_condition conditional edge - Add edge from "tools" back to "chat" for the ReAct loop - Separate backend_tools list from frontend tools (state["tools"]) This matches the standard LangGraph ReAct pattern (agent → tools → agent → ... → END) and how the HTTP/A2A templates use create_react_agent. Dev invoke fix: - invoke-agui.ts now tracks TOOL_CALL_START/ARGS/END/RESULT events - When no text is produced but tool calls were seen, surfaces them as [Tool: name(args)] instead of generic "(no content)" message * fix: address all review findings from AG-UI protocol code review 16 issues from 4-lane parallel code review, all addressed: Critical fixes: - Strands template: use session_manager_provider from ag-ui-strands 0.1.7 instead of hardcoded "default-session"/"default-user" - Dev client: persist threadId per session for multi-turn conversations - CRLF handling: use /\r?\n/ in SSE parsers (invoke-agui + invoke.ts) - Malformed JSON no longer yielded as content (shared parser skips) - Unbounded aguiEvents array replaced with bounded cursor-based pruning Structural improvements: - Unified SSE parser (agui-parser.ts) replaces two divergent parsers in invoke-agui.ts (dev) and agentcore.ts (deployed). Net -39 LOC. - Dual-consumer support with singleConsumer mode for dev path - AguiEvent type union completed (4 missing members added) - Dynamic imports converted to static where non-intentional (AGENTS.md) Python template fixes: - LangGraph: add LangchainInstrumentor + dep, remove unused END import, MemorySaver already removed in prior commit - GoogleADK: remove dead load_model() + bedrock-agentcore dep, remove hardcoded user_id (ADK defaults to per-thread identity) - Strands: bump ag-ui-strands pin to >= 0.1.7 enableOtel plumbing: - Dockerfile CMD conditional on enableOtel (Handlebars) - enableOtel threaded through AgentRenderConfig + BaseRenderer - Import path: ProtocolModeSchema.safeParse replaces unsafe as-cast - Import path: MCP enableOtel clamped regardless of YAML value - GoogleADK uses plain opentelemetry-distro (aws-distro conflicts) DX + testing: - formatZodIssue falls back to issue.code instead of literal "undefined" - New dockerfile-render.test.ts covers both enableOtel branches - All snapshots updated * fix: add AGUI to JSON schema protocol enum The static JSON schema file used for CDK validation was not updated when AGUI was added to the Zod schema. This caused CDK synth to reject protocol: "AGUI" with a misleading validation error. * fix: restore MemorySaver in AGUI LangGraph template ag_ui_langgraph calls aget_state(config) with thread_id which requires a checkpointer. Without it, every invocation throws ValueError: No checkpointer set. The original msgpack crash only triggers with numbers exceeding 2^63 (ormsgpack limitation), not with normal large numbers. Bug bash confirmed: 325435 + 435634563456456 works correctly with MemorySaver present. * fix: address final review findings in AGUI parser - Wrap reader.releaseLock() in try/catch to prevent error masking if lock is already released (HIGH from code review) - Replace textStream! non-null assertion with runtime guard (MEDIUM from code review) * fix: use toolCallId for TOOL_CALL_RESULT matching in dev client Previously matched by activeToolName which was already reset to '' by TOOL_CALL_END. The find() never matched, falling through to the last tool call — wrong for parallel tool calls. Now matches by toolCallId which is the unique identifier AG-UI provides per tool invocation. * revert: remove manual JSON schema edit (auto-generated during release) The schemas/ directory is auto-regenerated from Zod schemas during the release workflow. AGUI is already in ProtocolModeSchema (constants.ts) and will appear in the JSON schema on next release. * fix: add configurable PORT env var to AGUI templates + update snapshots All 3 AGUI templates now read PORT from env with default 8080: uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) Addresses PR review comment requesting configurable port for local testing. * fix: use AG-UI in user-facing strings instead of AGUI Schema enum stays 'AGUI' (internal), but TUI display text uses 'AG-UI' which is the protocol's official name. * fix: restore credential wiring in AGUI GoogleADK template The template was missing load_model() call and bedrock-agentcore dep, so GOOGLE_API_KEY was never set from the AgentCore credential. Both dev mode and deployed agents failed with "No API key provided." * fix: convert AGUI dynamic import to static in invokeForProtocol AGENTS.md requires all imports at top of file. The dynamic import had no meaningful performance benefit — AGUI parser is ~4KB in a 2.1MB CLI. * feat: support preview releases from feature branches (#905) The release workflow was hardcoded to only publish from main with the `latest` npm dist-tag. This made it impossible to publish prerelease versions from feature branches. Now when the workflow runs from a non-main branch, it sets the npm dist-tag to `preview` and targets the source branch for the release PR. Stable bump types (patch, minor, major) are blocked on non-main branches to prevent accidental overwrites of the `latest` tag. * fix(invoke): show full session ID and print resume command on exit (#904) * fix(invoke): show full session ID and print resume command on exit The invoke TUI truncated the session ID to 8 characters, making it impossible to copy the full UUID needed for --session-id. Additionally, there was no guidance on how to resume a session after exiting. - Display full session ID in the TUI header instead of truncating - Print a colored resume command after TUI exit (both Esc and Ctrl+C) - Use Ink's unmount() instead of process.exit(0) for clean shutdown, which also fixes the update notifier not showing on Esc exit * fix: only show resume message when a session was actually used * feat: add GovCloud multi-partition support (#908) Add partition-aware ARN construction, endpoint URL generation, and console URL generation to support aws-us-gov (and future aws-cn) partitions. - Create src/cli/aws/partition.ts with getPartition, arnPrefix, dnsSuffix, serviceEndpoint, and consoleDomain utilities - Replace all hardcoded arn:aws: in ARN template literals with arnPrefix(region) - Update ARN regex patterns to accept any partition (arn:[^:]+:) - Replace hardcoded amazonaws.com in endpoint URLs with serviceEndpoint() - Replace hardcoded console.aws.amazon.com with consoleDomain() - Add us-gov-west-1 to AgentCoreRegionSchema, BEDROCK_REGIONS, and LLM compacted types - Add aws-us-gov to cdk.json target-partitions - Fix execution-role-policy.json to use partition wildcard (arn:*) - Add 15 unit tests for partition utilities - Document multi-partition rules and checklists in AGENTS.md * feat: remove deployed/local from status legend (#936) * feat: remove deployed/local from status legend * fix: prettier * feat: upgrade agent inspector to 0.2.1 (#937) * fix(deploy): honor aws-targets.json region for all SDK and CDK calls (#925) * fix(deploy): honor aws-targets.json region for all SDK and CDK calls (#924) AWS SDK clients constructed by @aws-cdk/toolkit-lib internally (for CloudFormation, S3 asset upload, etc.) do not receive an explicit region option and fall back to the SDK's default region resolution chain (AWS_REGION -> AWS_DEFAULT_REGION -> shared config). When a user's aws-targets.json specified a non-default region but those env vars were unset, resources were created in the SDK default region instead of the configured target. Promote target.region to AWS_REGION and AWS_DEFAULT_REGION for the lifetime of deploy and teardown operations, restoring prior values in a finally block. This ensures downstream SDK clients (explicit and toolkit-lib internal) agree on the target region. Covers CLI non-interactive deploy (handleDeploy) and the interactive TUI deploy/teardown (useCdkPreflight, destroyTarget). Invoke/status/eval already pass target.region explicitly. * fix(deploy): restore region env on TUI error states; consolidate barrel exports Review feedback: 1. TUI preflight error branches called setPhase('error') without calling restoreRegionEnv(). Add a useEffect guarded on phase === 'error' so every error path restores the env override without threading the call into every branch. 2. Export applyTargetRegionToEnv from the aws barrel for consistency with withTargetRegion. Update CLI deploy, teardown, and TUI preflight hook to import from the barrel instead of the deep path. * chore: bump version to 0.10.0 (#944) Co-authored-by: github-actions[bot] * chore: sync with public/main (2026-04-27) (#143) * 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> * feat: add target-based AB test routing Adds a new AB test mode that routes traffic between gateway targets pointing at different runtime endpoints, alongside the existing config-bundle mode. Changes: - AB test schema: target-based mode, per-variant eval config, gateway filter - HTTP gateway: targets array with qualifier (endpoint reference) - AB test primitive: --mode flag, target-based CLI flow - Pause/resume/stop/promote commands for AB tests - TUI: mode selection, target-based wizard steps - Cross-ref validation: gateway targets must reference valid endpoints - Deploy: handle target-based variant resolution and eval config union * fix: correct API field names for target-based AB test creation - Rename target.targetName → target.name in API client - Rename perVariantOnlineEvaluationConfig[].treatmentName → .name - Fix post-deploy mapping to use correct API field names - Add controlQualifier/treatmentQualifier to Commander options type - Add runtime/qualifier fields to TUI AB test config types * fix: include all eval config ARNs in AB test IAM role policy For target-based AB tests with perVariantOnlineEvaluationConfig, the IAM role was only granted access to the first eval config ARN. Now passes all eval config ARNs to the role policy so the AB test API can validate access to both control and treatment eval configs. * feat: create gateway targets with qualifier during post-deploy HTTP gateway targets from httpGateways[].targets are now created via the API after the gateway is provisioned. Adds qualifier parameter to createHttpGatewayTarget so targets can reference specific runtime endpoints instead of DEFAULT. * feat: implement TUI steps 4-6 for target-based AB test wizard Step 4 (Gateway): Picker for existing HTTP gateways + create new with inline text input for name. Step 5 (Control Target): Three-section grouped picker: - Existing targets on the selected gateway - Runtime endpoints (auto-creates target on select) - Create new target sub-flow (runtime → name → qualifier picker showing DEFAULT + all endpoints with versions) Step 6 (Treatment Target): Same picker as step 5, filters out control selection, shows control context at top. All state held in wizard config until confirm step. * fix: always wait for IAM policy propagation and retry AB test creation 1. Always wait 15s after PutRolePolicy, not just for new roles — policy updates on existing roles also need propagation time. 2. Retry createABTest up to 3 times with 10s delay on gateway access denied errors caused by IAM propagation lag. * fix: delete and recreate stale AB tests found by name lookup When an AB test exists from a previous failed deploy, it may have stale eval config ARNs. Instead of skipping, delete it and fall through to recreation with fresh resolved ARNs from deployed state. * fix: always delete and recreate AB tests on deploy AB tests were skipped when found in deployed state, even if eval config ARNs had changed. Now deletes existing AB test and recreates with fresh resolved ARNs from current deployed state on every deploy. * feat: add endpoint support to online eval configs Online eval configs can now be scoped to a specific runtime endpoint. When endpoint is specified, the eval monitors only that endpoint's traffic instead of all traffic on the runtime. - Schema: optional endpoint field on OnlineEvaluationConfig - CLI: --endpoint flag on add online-eval with validation - Deployed state: carries agent + endpoint for AB test resolution - L3 CDK: uses config.endpoint in log group and service names * fix: resolve AB test region from deployed state instead of defaulting to us-east-1 pause, resume, stop, promote, and ab-test detail commands now read the region from aws-targets.json for the target where the AB test was deployed, instead of falling back to us-east-1. * feat: clean up AB test CLI for target-based mode - Remove --control-target/--treatment-target (auto-generated from runtime-qualifier) - Remove --evaluators/--sampling-rate (no auto-create eval configs) - Add --control-online-eval/--treatment-online-eval (project eval name) - Require existing online eval configs scoped to endpoints - Auto-generate gateway target names as {runtime}-{qualifier} - Only auto-remove eval configs with {testName}_eval_ prefix on cascade * fix: create missing gateway targets on existing gateways during deploy When an HTTP gateway already exists in deployed state, additional targets from httpGateways[].targets were never created. Now creates missing targets on existing gateways (409 conflicts skipped). * fix: add ListGatewayRules permission to AB test IAM role AB test service needs ListGatewayRules to manage gateway routing rules during AB test creation and teardown. * feat: TUI updates for online eval endpoint picker and AB test target/eval filtering Online eval TUI: - Endpoint picker step after runtime selection (DEFAULT + endpoints with versions) - Skips step if runtime has no endpoints AB test TUI steps 5-6: - Auto-generate target names from runtime-qualifier (no manual naming) - Removed target name text input from create sub-flow AB test TUI step 8: - Filter eval configs by matching runtime + endpoint - Show error with agentcore add command if no matching eval exists * feat: post-deploy robustness — update instead of recreate, qualifier detection, role cleanup 1. AB tests: hash config and compare on deploy. Skip if unchanged (preserves running state + metrics). Update via API if changed instead of delete+recreate. 2. Gateway targets: detect qualifier changes on existing targets. Delete and recreate targets whose qualifier differs from spec. 3. Orphaned IAM role cleanup: delete auto-created role when AB test creation fails, preventing leaked IAM resources. * fix: register promote command in CLI registerPromote was exported from pause/command.tsx but never wired into cli.ts. Added to pause/index.ts exports and registered in cli.ts. * feat: AB test name scoping, role cleanup, promote auto-applies treatment 1. AB test names prefixed with project name on API side to prevent cross-project collisions. CLI resolves with prefix + bare name fallback. 2. Orphaned IAM role cleanup when AB test creation fails. 3. Promote now auto-modifies agentcore.json: - Target-based: updates control endpoint version to treatment's version - Config-bundle: updates control bundle to treatment's bundle/version User just needs to run agentcore deploy after promote. * refactor: extract promote logic into shared promoteABTestConfig() Move AB test promotion logic from pause/command.tsx into operations/ab-test/promote.ts. Both CLI and TUI now call the same shared function. Supports project-prefixed name matching. * feat: improve AB test TUI for target-based mode - Hide Online Eval header for per-variant (target-based) mode - Debug panel checks each per-variant eval config separately - Eval results checked per-variant log group instead of single group - W key runs full promote logic inline (stop + update config) - Add W promote to help keys bar - Hide promote from TUI command list - Fix hardcoded amazonaws.com to use dnsSuffix() for multi-partition * fix: remove cascade delete of targets/gateways on AB test removal AB test removal no longer auto-deletes gateway targets or orphaned gateways. This prevents accidentally removing targets shared by multiple AB tests. Users can manually remove targets with agentcore remove gateway-target. * fix: case-insensitive promote name matching, improve promote message * fix: rename qualifier to endpoint in AB test TUI display labels * fix: clarify stop confirmation message — traffic shifts to control * feat: context-sensitive help keys in AB test detail screen Show only relevant actions based on execution status: - RUNNING: P pause, S stop, W promote, D debug - PAUSED: R resume, S stop, W promote, D debug - STOPPED: D debug only * feat: rename qualifier to endpoint flags, add grouped CLI help - Rename --control-qualifier → --control-endpoint - Rename --treatment-qualifier → --treatment-endpoint - Add hidden deprecation aliases for backwards compatibility - Add grouped help output (Common, Config-Bundle, Target-Based sections) - Update internal field names across TUI types, wizard, and flow - Schema qualifier field in agentcore.json unchanged * chore: update AddABTestScreen qualifier → endpoint field references * feat: hide mode-specific flags from default help, show in grouped sections * chore: fix prettier formatting in unrelated files * feat: add side-by-side builder for target-based AB tests New TargetBasedABTestScreen with two-column layout for control and treatment configuration. Includes usePanelNavigation hook for 2D focus management and useTargetBasedWizard state machine. Multi-field name/description form. Config-bundle flow unchanged. * feat: route target-based mode to new TargetBasedABTestScreen Add onSwitchToTargetBased callback to AddABTestScreen. When user selects target-based mode, redirects to the new side-by-side builder instead of the shared wizard. Config-bundle flow unchanged. * chore: add sync-preview job to sync public/preview into preview (#149) * fix: rename runtimeTargetConfiguration to agentcoreRuntime Update gateway target creation to use new Smithy field name agentcoreRuntime (per CR-270497293). Falls back to legacy runtimeTargetConfiguration if the new name is not yet supported. Response reading checks both field names. Also fix hardcoded amazonaws.com and let→const lint issues. * chore: remove ABTest4 test project accidentally committed in merge * chore: remove remaining ABTest4 cdk.out files * fix: Enter advances from builder when all fields complete Add onComplete/allFieldsComplete to usePanelNavigation hook. When all builder fields are configured, Enter advances to the next wizard step instead of activating the focused field. * fix: clean up ab-test help — remove duplicate Common Options, add descriptions to mode sections * fix: enforce XOR on variant configuration schema Replace permissive optional fields with a Zod union so exactly one of configurationBundle or target can be set, not both. * fix: promote uses deployed state to resolve AB test by ID Change promoteABTestConfig to accept abTestId instead of testName. It now reads deployed state to find the spec name, with a name-based fallback when deployed state is unavailable. * fix: extract shared applyABTestUpdate helper and improve retry loop Extract duplicated update logic into applyABTestUpdate() helper. Fix retry loop to use error codes/status, exponential backoff with jitter, 5 retries, and 5s initial delay. Also fix pre-existing lint issues: hardcoded ARN partitions and unused variable. * fix: add --delete-gateway flag for cascade delete on ab-test remove When --delete-gateway is passed, remove gateway targets created for the AB test's variants and delete the gateway itself if no other AB tests reference it. Default behavior (no flag) only removes from agentcore.json. * test: add unit tests for promoteABTestConfig Cover target-based promote, config-bundle promote, not-found case, ID-based lookup from deployed state, and name fallback when deployed state is missing. * fix: validate AB test name in TUI builder before accepting * fix: builder auto-advances field by field after selection Enter always opens the picker. After a selection, auto-advances to the next field in sequence (control endpoint → weight → eval → treatment endpoint → weight → eval → complete). Matches the runtime endpoint multi-field pattern. * fix: scope logs:DescribeLogGroups to Resource * for AB test role DescribeLogGroups requires Resource: * per AWS API limitation. Previously scoped to specific log group ARNs which caused Access denied on logs:DescribeLogGroups failures. * test: add unit tests for usePanelNavigation, useTargetBasedWizard, and http-gateways 47 tests covering panel navigation, wizard state machine, and gateway API with agentcoreRuntime fallback. --------- Signed-off-by: dependabot[bot] Co-authored-by: github-actions[bot] Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Co-authored-by: Álvaro <159990212+alvarog2491@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Jesse Turner Co-authored-by: Yasuhiro Horiuchi Co-authored-by: kashinoki38 <21358299+kashinoki38@users.noreply.github.com> Co-authored-by: padmak30 Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Gitika <53349492+notgitika@users.noreply.github.com> Co-authored-by: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: padmak30 --- .github/workflows/sync-from-public.yml | 93 +++ .../__tests__/agentcore-http-gateways.test.ts | 235 ++++++ src/cli/aws/agentcore-ab-tests.ts | 20 +- src/cli/aws/agentcore-http-gateways.ts | 39 +- src/cli/cli.ts | 3 +- .../__tests__/outputs-extended.test.ts | 8 +- src/cli/cloudformation/outputs.ts | 10 +- src/cli/commands/abtest/command.ts | 44 +- src/cli/commands/create/action.ts | 2 +- src/cli/commands/deploy/actions.ts | 16 +- src/cli/commands/pause/command.tsx | 103 ++- src/cli/commands/pause/index.ts | 2 +- .../ab-test/__tests__/promote.test.ts | 270 +++++++ src/cli/operations/ab-test/promote.ts | 124 +++ .../__tests__/post-deploy-ab-tests.test.ts | 1 + .../operations/deploy/post-deploy-ab-tests.ts | 322 ++++++-- .../deploy/post-deploy-http-gateways.ts | 106 +++ src/cli/primitives/ABTestPrimitive.ts | 548 +++++++++++--- src/cli/primitives/AgentPrimitive.tsx | 8 +- .../primitives/OnlineEvalConfigPrimitive.ts | 20 + .../__tests__/usePanelNavigation.test.tsx | 347 +++++++++ src/cli/tui/hooks/useCreateABTest.ts | 19 +- src/cli/tui/hooks/useCreateOnlineEval.ts | 2 + src/cli/tui/hooks/usePanelNavigation.ts | 196 +++++ .../screens/ab-test/ABTestDetailScreen.tsx | 225 ++++-- src/cli/tui/screens/ab-test/AddABTestFlow.tsx | 92 ++- .../tui/screens/ab-test/AddABTestScreen.tsx | 711 +++++++++++++++-- .../ab-test/TargetBasedABTestScreen.tsx | 712 ++++++++++++++++++ .../__tests__/useTargetBasedWizard.test.tsx | 319 ++++++++ src/cli/tui/screens/ab-test/types.ts | 46 ++ .../tui/screens/ab-test/useAddABTestWizard.ts | 198 ++++- .../screens/ab-test/useTargetBasedWizard.ts | 188 +++++ src/cli/tui/screens/agent/useAddAgent.ts | 2 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 18 +- src/cli/tui/screens/generate/types.ts | 15 +- .../screens/online-eval/AddOnlineEvalFlow.tsx | 18 +- .../online-eval/AddOnlineEvalScreen.tsx | 65 +- src/cli/tui/screens/online-eval/types.ts | 17 +- .../online-eval/useAddOnlineEvalWizard.ts | 45 +- .../recommendation/RecommendationFlow.tsx | 7 +- .../run-eval/BatchEvalHistoryScreen.tsx | 7 +- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 7 +- src/cli/tui/utils/commands.ts | 2 +- src/schema/schemas/agentcore-project.ts | 39 + src/schema/schemas/deployed-state.ts | 6 + src/schema/schemas/primitives/ab-test.ts | 70 +- src/schema/schemas/primitives/http-gateway.ts | 13 + .../schemas/primitives/online-eval-config.ts | 2 + 48 files changed, 4960 insertions(+), 402 deletions(-) create mode 100644 src/cli/aws/__tests__/agentcore-http-gateways.test.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/tui/hooks/__tests__/usePanelNavigation.test.tsx create mode 100644 src/cli/tui/hooks/usePanelNavigation.ts create mode 100644 src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx create mode 100644 src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx create mode 100644 src/cli/tui/screens/ab-test/useTargetBasedWizard.ts 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 }} 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/agentcore-ab-tests.ts b/src/cli/aws/agentcore-ab-tests.ts index 7d018c6c3..e78fb23c0 100644 --- a/src/cli/aws/agentcore-ab-tests.ts +++ b/src/cli/aws/agentcore-ab-tests.ts @@ -19,15 +19,27 @@ export interface ABTestVariant { name: 'C' | 'T1'; weight: number; variantConfiguration: { - configurationBundle: { + configurationBundle?: { bundleArn: string; bundleVersion: string; }; + target?: { + name: string; + }; }; } -export interface ABTestEvaluationConfig { - onlineEvaluationConfigArn: string; +export type ABTestEvaluationConfig = + | { onlineEvaluationConfigArn: string } + | { + perVariantOnlineEvaluationConfig: { + name: 'C' | 'T1'; + onlineEvaluationConfigArn: string; + }[]; + }; + +export interface GatewayFilter { + targetPaths: string[]; } export interface TrafficAllocationConfig { @@ -79,6 +91,7 @@ export interface CreateABTestOptions { roleArn: string; variants: ABTestVariant[]; evaluationConfig: ABTestEvaluationConfig; + gatewayFilter?: GatewayFilter; trafficAllocationConfig?: TrafficAllocationConfig; maxDurationDays?: number; enableOnCreate?: boolean; @@ -301,6 +314,7 @@ export async function createABTest(options: CreateABTestOptions): Promise { '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/outputs.ts b/src/cli/cloudformation/outputs.ts index e020839df..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 }), }; } } diff --git a/src/cli/commands/abtest/command.ts b/src/cli/commands/abtest/command.ts index 71219312b..3882aeabf 100644 --- a/src/cli/commands/abtest/command.ts +++ b/src/cli/commands/abtest/command.ts @@ -27,15 +27,29 @@ async function getRegion(cliRegion?: string): Promise { return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; } -async function resolveABTestId(testName: string, region: string): Promise<{ abTestId: string; error?: string }> { +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(); - for (const target of Object.values(deployedState.targets ?? {})) { + 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]) { - return { abTestId: abTests[testName].abTestId }; + const targetConfig = awsTargets.find(t => t.name === targetName); + const resolvedRegion = targetConfig?.region ?? region; + return { abTestId: abTests[testName].abTestId, region: resolvedRegion }; } } } catch { @@ -44,15 +58,17 @@ async function resolveABTestId(testName: string, region: string): Promise<{ abTe try { const result = await listABTests({ region, maxResults: 100 }); - const match = result.abTests.find(t => t.name === testName); + // 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 && t.name === prefixedName) || t.name === testName); if (match) { - return { abTestId: match.abTestId }; + return { abTestId: match.abTestId, region }; } } catch { // API call failed } - return { abTestId: '', error: `AB test "${testName}" not found in deployed state or API.` }; + return { abTestId: '', region, error: `AB test "${testName}" not found in deployed state or API.` }; } function gatewayUrlFromArn(arn: string): string { @@ -71,14 +87,21 @@ function formatABTestDetails(test: GetABTestResult): string { lines.push(` Status: ${test.status}`); lines.push(` Execution: ${test.executionStatus}`); lines.push(` Invocation URL: ${gatewayUrlFromArn(test.gatewayArn)}//invocations`); - lines.push(` Online Eval: ${test.evaluationConfig.onlineEvaluationConfigArn}`); + 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; - lines.push( - ` Variant ${variant.name}: weight=${variant.weight}, bundle=${bundleRef.bundleArn}, version=${bundleRef.bundleVersion}` - ); + 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. @@ -139,7 +162,6 @@ export function registerABTestCommand(program: Command): void { } process.exit(1); } - const result = await getABTest({ region, abTestId }); if (cliOptions.json) { diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index 487750479..a00397f38 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -11,12 +11,12 @@ 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, writeAgentToProject, } from '../../operations/agent/generate'; -import { createConfigBundleForAgent } from '../../operations/agent/config-bundle-defaults'; import { executeImportAgent } from '../../operations/agent/import'; import { credentialPrimitive } from '../../primitives/registry'; import { createDefaultProjectSpec } from '../../project'; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 6a720e5ff..acae6fb03 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -397,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 ?? []; @@ -473,14 +477,14 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise !previouslyDeployedOnlineEvals[c.name]); - if (newOnlineEvalSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { + const newOnlineEvalFullSpecs = onlineEvalFullSpecs.filter(c => !previouslyDeployedOnlineEvals[c.name]); + if (newOnlineEvalFullSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { const enableResult = await enableOnlineEvalConfigs({ region: target.region, - onlineEvalConfigs: newOnlineEvalSpecs, + onlineEvalConfigs: newOnlineEvalFullSpecs, deployedOnlineEvalConfigs, }); diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index 70e835b77..b5b888464 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -83,14 +83,29 @@ async function getRegion(cliRegion?: string): Promise { return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; } -async function resolveABTestId(testName: string, region: string): Promise<{ abTestId: string; error?: string }> { +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(); - for (const target of Object.values(deployedState.targets ?? {})) { + 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]) { - return { abTestId: abTests[testName].abTestId }; + const targetConfig = awsTargets.find(t => t.name === targetName); + const resolvedRegion = targetConfig?.region ?? region; + return { abTestId: abTests[testName].abTestId, region: resolvedRegion }; } } } catch { @@ -99,13 +114,17 @@ async function resolveABTestId(testName: string, region: string): Promise<{ abTe try { const result = await listABTests({ region, maxResults: 100 }); - const match = result.abTests.find(t => t.name === testName); - if (match) return { abTestId: match.abTestId }; + // 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: '', error: `AB test "${testName}" not found in deployed state or API.` }; + return { abTestId: '', region, error: `AB test "${testName}" not found in deployed state or API.` }; } function registerABTestSubcommand(parent: Command, action: 'pause' | 'resume') { @@ -278,3 +297,75 @@ export const registerStop = (program: Command) => { } }); }; + +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); + } + + // Stop the AB test + const result = await updateABTest({ + region, + abTestId, + executionStatus: 'STOPPED', + }); + + // 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/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..812b780c9 --- /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/deploy/__tests__/post-deploy-ab-tests.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts index 7e7848c5a..962081e0a 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 @@ -74,6 +74,7 @@ function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCo const sampleABTest = { name: 'TestOne', + mode: 'config-bundle' as const, gatewayRef: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', variants: [ { diff --git a/src/cli/operations/deploy/post-deploy-ab-tests.ts b/src/cli/operations/deploy/post-deploy-ab-tests.ts index e98971fc7..63ec1cd44 100644 --- a/src/cli/operations/deploy/post-deploy-ab-tests.ts +++ b/src/cli/operations/deploy/post-deploy-ab-tests.ts @@ -2,6 +2,7 @@ import type { ABTestDeployedState, AgentCoreProjectSpec, DeployedResourceState } 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, @@ -45,6 +46,76 @@ export interface SetupABTestsResult { 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 // ============================================================================ @@ -64,37 +135,12 @@ export async function setupABTests(options: SetupABTestsOptions): Promise pv.onlineEvaluationConfigArn); if (testSpec.roleArn) { resolvedRoleArn = testSpec.roleArn; } else { @@ -119,29 +165,110 @@ export async function setupABTests(options: SetupABTestsOptions): Promise 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({ @@ -151,6 +278,14 @@ export async function setupABTests(options: SetupABTestsOptions): Promise { try { + const prefixedName = `${projectName}_${testName}`; const result = await listABTests({ region, maxResults: 100 }); - return result.abTests.find(t => t.name.toLowerCase() === name.toLowerCase()); + return result.abTests.find( + t => t.name.toLowerCase() === prefixedName.toLowerCase() || t.name.toLowerCase() === testName.toLowerCase() + ); } catch { return undefined; } @@ -270,29 +409,42 @@ async function findABTestByName( /** * Resolve variant config bundle references. * If bundleArn is a name (not an ARN), look it up in deployed config bundles. + * Target-based variants are passed through as-is. */ function resolveVariants( variants: { name: 'C' | 'T1'; weight: number; - variantConfiguration: { configurationBundle: { bundleArn: string; bundleVersion: string } }; + variantConfiguration: { + configurationBundle?: { bundleArn: string; bundleVersion: string }; + target?: { targetName: string }; + }; }[], deployedResources?: DeployedResourceState ): ABTestVariant[] { - return variants.map(v => ({ - name: v.name, - weight: v.weight, - variantConfiguration: { - configurationBundle: { - bundleArn: resolveConfigBundleArn(v.variantConfiguration.configurationBundle.bundleArn, deployedResources), - bundleVersion: resolveConfigBundleVersion( - v.variantConfiguration.configurationBundle.bundleArn, - v.variantConfiguration.configurationBundle.bundleVersion, - deployedResources - ), + 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 — pass through + return { + name: v.name, + weight: v.weight, + variantConfiguration: { + ...(v.variantConfiguration.target && { target: { name: v.variantConfiguration.target.targetName } }), }, - }, - })); + }; + }); } function resolveConfigBundleArn(ref: string, deployedResources?: DeployedResourceState): string { @@ -345,20 +497,34 @@ function resolveGatewayArn(ref: string, deployedResources?: DeployedResourceStat } function resolveEvalConfig( - config: { onlineEvaluationConfigArn: string }, + 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) }; +} - if (ref.startsWith('arn:')) return { onlineEvaluationConfigArn: ref }; +function resolveOnlineEvalArn(ref: string, deployedResources?: DeployedResourceState): string { + if (ref.startsWith('arn:')) return ref; - // Try to resolve from deployed online eval configs const configs = deployedResources?.onlineEvalConfigs; if (configs?.[ref]) { - return { onlineEvaluationConfigArn: configs[ref].onlineEvaluationConfigArn }; + return configs[ref].onlineEvaluationConfigArn; } - return { onlineEvaluationConfigArn: ref }; + return ref; } // ============================================================================ @@ -390,11 +556,11 @@ interface CreateABTestRoleOptions { projectName: string; testName: string; gatewayArn: string; - onlineEvalConfigArn: string; + onlineEvalConfigArns: string[]; } async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise { - const { region, projectName, testName, gatewayArn, onlineEvalConfigArn } = options; + const { region, projectName, testName, gatewayArn, onlineEvalConfigArns } = options; const credentials = getCredentialProvider(); const iamClient = new IAMClient({ region, credentials }); @@ -417,7 +583,7 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< }); let roleArn: string; - let needsPropagationWait = false; + let _needsPropagationWait = false; try { const createResult = await iamClient.send( @@ -437,7 +603,7 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< if (!roleArn) { throw new Error(`IAM CreateRole succeeded but returned no role ARN for "${roleName}"`); } - needsPropagationWait = true; + _needsPropagationWait = true; } catch (err: unknown) { // Handle retry after a previous failed deploy left the role behind const errName = (err as { name?: string }).name; @@ -464,14 +630,15 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< 'bedrock-agentcore:UpdateGatewayRule', 'bedrock-agentcore:GetGatewayRule', 'bedrock-agentcore:DeleteGatewayRule', + 'bedrock-agentcore:ListGatewayRules', ], - Resource: [`arn:aws:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayId}`], + Resource: [`${arnPrefix(region)}:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayId}`], }, { Sid: 'GatewayReadStatement', Effect: 'Allow', Action: ['bedrock-agentcore:GetGateway'], - Resource: [`arn:aws:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayId}`], + Resource: [`${arnPrefix(region)}:bedrock-agentcore:${region}:${accountId}:gateway/${gatewayId}`], }, { Sid: 'GatewayListStatement', @@ -483,19 +650,24 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< Sid: 'OnlineEvaluationConfigStatement', Effect: 'Allow', Action: ['bedrock-agentcore:GetOnlineEvaluationConfig', 'bedrock-agentcore:UpdateOnlineEvaluationConfig'], - Resource: [onlineEvalConfigArn], + Resource: onlineEvalConfigArns, }, { Sid: 'ConfigurationBundleReadStatement', Effect: 'Allow', Action: ['bedrock-agentcore:GetConfigurationBundle', 'bedrock-agentcore:GetConfigurationBundleVersion'], - Resource: [`arn:aws:bedrock-agentcore:${region}:${accountId}:configuration-bundle/*`], + Resource: [`${arnPrefix(region)}:bedrock-agentcore:${region}:${accountId}:configuration-bundle/*`], + }, + { + Sid: 'CloudWatchDescribeLogGroups', + Effect: 'Allow', + Action: ['logs:DescribeLogGroups'], + Resource: ['*'], }, { Sid: 'CloudWatchLogReadStatement', Effect: 'Allow', Action: [ - 'logs:DescribeLogGroups', 'logs:StartQuery', 'logs:GetQueryResults', 'logs:StopQuery', @@ -503,10 +675,10 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< 'logs:GetLogEvents', ], Resource: [ - `arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*`, - `arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*:*`, - `arn:aws:logs:${region}:${accountId}:log-group:aws/spans`, - `arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*:*`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:aws/spans`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:aws/spans:*`, ], }, { @@ -514,8 +686,8 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< Effect: 'Allow', Action: ['logs:DescribeIndexPolicies', 'logs:PutIndexPolicy'], Resource: [ - `arn:aws:logs:${region}:${accountId}:log-group:aws/spans`, - `arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:aws/spans`, + `${arnPrefix(region)}:logs:${region}:${accountId}:log-group:aws/spans:*`, ], }, ], @@ -530,10 +702,8 @@ async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise< }) ); - if (needsPropagationWait) { - // Wait for IAM role propagation before creating the AB test - await new Promise(resolve => setTimeout(resolve, 15_000)); - } + // 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; } diff --git a/src/cli/operations/deploy/post-deploy-http-gateways.ts b/src/cli/operations/deploy/post-deploy-http-gateways.ts index 9aa348805..898b12488 100644 --- a/src/cli/operations/deploy/post-deploy-http-gateways.ts +++ b/src/cli/operations/deploy/post-deploy-http-gateways.ts @@ -5,6 +5,7 @@ import { createHttpGatewayTarget, deleteHttpGateway, deleteHttpGatewayTarget, + getHttpGatewayTarget, listAllHttpGateways, listHttpGatewayTargets, waitForGatewayReady, @@ -92,6 +93,82 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom if (existingGateway) { // Already deployed — ensure trace delivery is enabled (may have failed on initial deploy) await ensureTraceDelivery({ region, gatewayName: gwSpec.name, gatewayArn: existingGateway.gatewayArn }); + + // 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(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: 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, @@ -251,6 +328,35 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom 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: 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, diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts index 6b7249270..97d233401 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 { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -27,6 +28,22 @@ export interface AddABTestOptions { 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; /** @@ -52,7 +69,7 @@ export class ABTestPrimitive extends BasePrimitive { + async remove(testName: string, options?: { deleteGateway?: boolean }): Promise { try { const project = await this.readProjectSpec(); @@ -64,20 +81,40 @@ export class ABTestPrimitive extends BasePrimitive 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]; + 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 && m[1] === gwName; + return m?.[1] === gwName; }); if (!stillReferenced) { - const gwIndex = project.httpGateways.findIndex(gw => gw.name === gwName); - if (gwIndex !== -1) { - project.httpGateways.splice(gwIndex, 1); - } + project.httpGateways = project.httpGateways.filter(gw => gw.name !== gwName); } } } @@ -154,130 +191,324 @@ export class ABTestPrimitive extends BasePrimitive', '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 for the AB test (auto-created if not provided)') - .option('--control-bundle ', 'Control variant config bundle name or ARN') - .option('--control-version ', 'Control variant config bundle version') - .option('--treatment-bundle ', 'Treatment variant config bundle name or ARN') - .option('--treatment-version ', 'Treatment variant config bundle version') + .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 ', 'Use an existing HTTP gateway (skips auto-creation and --runtime)') - .option('--online-eval ', 'Online evaluation config name (resolved from project)') + .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') - .action( - async (cliOptions: { - name?: string; - description?: string; - runtime?: string; - gateway?: string; - roleArn?: string; - controlBundle?: string; - controlVersion?: string; - treatmentBundle?: string; - treatmentVersion?: string; - controlWeight?: number; - treatmentWeight?: number; - onlineEval?: string; - trafficHeader?: string; - maxDuration?: number; - enable?: boolean; - json?: boolean; - }) => { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); + .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 || 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'); - 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.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'); - if (!cliOptions.onlineEval) fail('--online-eval is required'); - const result = await this.add({ + // 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, - 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!, + gateway: cliOptions.gateway!, + runtime: cliOptions.runtime!, + roleArn: cliOptions.roleArn, + controlEndpoint: resolvedControlEndpoint!, + treatmentEndpoint: resolvedTreatmentEndpoint!, controlWeight: cliOptions.controlWeight!, treatmentWeight: cliOptions.treatmentWeight!, - onlineEval: cliOptions.onlineEval!, - trafficHeaderName: cliOptions.trafficHeader, - maxDurationDays: cliOptions.maxDuration, + 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 AB test '${result.abTestName}'`); + console.log(`Added target-based 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, - onExit: () => { - clear(); - unmount(); - process.exit(0); - }, - }) - ); + return; } - } catch (error) { + + // 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({ success: false, error: getErrorMessage(error) })); + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added AB test '${result.abTestName}'`); } else { - console.error(getErrorMessage(error)); + console.error(result.error); } - process.exit(1); + 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, + 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); + } - this.registerRemoveSubcommand(removeCmd); + 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 { @@ -322,6 +553,7 @@ export class ABTestPrimitive extends BasePrimitive> { + 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.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.push(abTest); + await this.writeProjectSpec(project); + + return abTest; + } } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index ebc2b0c57..5d0196290 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -25,13 +25,13 @@ 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, mapModelProviderToIdentityProviders, writeAgentToProject, } from '../operations/agent/generate'; -import { createConfigBundleForAgent } from '../operations/agent/config-bundle-defaults'; import { executeImportAgent } from '../operations/agent/import'; import { setupPythonProject } from '../operations/python'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; @@ -255,7 +255,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( + '--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()) { @@ -614,5 +617,4 @@ export class AgentPrimitive 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,6 +120,7 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { @@ -160,6 +163,7 @@ 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/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 index 22dbf13e0..e54666074 100644 --- a/src/cli/tui/hooks/useCreateABTest.ts +++ b/src/cli/tui/hooks/useCreateABTest.ts @@ -1,3 +1,4 @@ +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'; @@ -53,11 +54,27 @@ export function useCreateABTest() { } }, []); + 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, reset }; + return { status, createABTest: create, createTargetBasedABTest: createTargetBased, reset }; } export function useExistingABTestNames() { diff --git a/src/cli/tui/hooks/useCreateOnlineEval.ts b/src/cli/tui/hooks/useCreateOnlineEval.ts index 2d0190552..7913f5bd1 100644 --- a/src/cli/tui/hooks/useCreateOnlineEval.ts +++ b/src/cli/tui/hooks/useCreateOnlineEval.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; interface CreateOnlineEvalConfig { name: string; agent: string; + endpoint?: string; evaluators: string[]; samplingRate: number; enableOnCreate: boolean; @@ -20,6 +21,7 @@ export function useCreateOnlineEval() { const addResult = await onlineEvalConfigPrimitive.add({ name: config.name, agent: config.agent, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), evaluators: config.evaluators, samplingRate: config.samplingRate, enableOnCreate: config.enableOnCreate, 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/screens/ab-test/ABTestDetailScreen.tsx b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx index 5ef6b5638..a63cce50b 100644 --- a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx +++ b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx @@ -3,6 +3,7 @@ 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 type { Delivery, DeliverySource } from '@aws-sdk/client-cloudwatch-logs'; @@ -27,7 +28,7 @@ function gatewayUrlFromArn(arn: string): string { const region = parts[3]; const gatewayId = parts[5]?.split('/')[1]; if (region && gatewayId) { - return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.amazonaws.com`; + return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}`; } return arn; } @@ -90,18 +91,28 @@ async function runDebugChecks(test: GetABTestResult, region: string): Promise ({ + 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 @@ -176,59 +187,53 @@ async function runDebugChecks(test: GetABTestResult, region: string): Promise 0; - const controlCount = controlEvents.events?.length ?? 0; - const treatmentCount = treatmentEvents.events?.length ?? 0; - - if (!hasResults) { - results.push({ - label: 'Eval Results (last 30m)', - status: 'warn', - detail: 'No eval results yet — wait ~5m after session timeout for evaluator to process', - }); - } else { - const tagged = controlCount + treatmentCount; + // 5. 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 (last 30m)', - status: tagged > 0 ? 'pass' : 'warn', - detail: - tagged > 0 - ? `C: ${controlCount}, T1: ${treatmentCount}` - : 'Results exist but none tagged with variant — check gateway trace delivery', + label: `Eval Results${labelSuffix}`, + status: msg.includes('ResourceNotFoundException') ? 'warn' : 'fail', + detail: msg.includes('ResourceNotFoundException') ? 'Log group not found — evaluator has not run yet' : msg, }); } - } catch (err) { - const msg = getErrorMessage(err); - results.push({ - label: 'Eval Results', - status: msg.includes('ResourceNotFoundException') ? 'warn' : 'fail', - detail: msg.includes('ResourceNotFoundException') ? 'Log group not found — evaluator has not run yet' : msg, - }); } // 6. Aggregation Results @@ -251,6 +256,7 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr 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(''); @@ -317,6 +323,44 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr 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'); } @@ -330,6 +374,11 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr setActionMessage(null); } + if (input === 'w' || input === 'W') { + setConfirmingPromote(true); + setActionMessage(null); + } + if (input === 'd' || input === 'D') { setDebugLoading(true); setDebugResults(null); @@ -367,7 +416,14 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr const executionColor = test.executionStatus === 'RUNNING' ? 'green' : test.executionStatus === 'PAUSED' ? 'yellow' : 'red'; - const helpKeys = 'P pause · R resume · S stop · D debug · Esc exit'; + 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} ` : ''; @@ -401,10 +457,12 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr )} - {/* ── Header: Line 3 — online eval ────────────────────── */} - - {`Online Eval: ${extractId(test.evaluationConfig.onlineEvaluationConfigArn)}`} - + {/* ── 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 && ( @@ -418,16 +476,20 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr {'CONTROL (C)'} {`${String(controlVariant?.weight ?? 'N/A')}% traffic`} - {`${extractId(controlVariant?.variantConfiguration.configurationBundle.bundleArn ?? '')} @ ${shortVersion(controlVariant?.variantConfiguration.configurationBundle.bundleVersion ?? '')}`} + + {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`} - {`${extractId(treatmentVariant?.variantConfiguration.configurationBundle.bundleArn ?? '')} @ ${shortVersion(treatmentVariant?.variantConfiguration.configurationBundle.bundleVersion ?? '')}`} + + {treatmentVariant?.variantConfiguration.target + ? `target: ${treatmentVariant.variantConfiguration.target.name}` + : `${extractId(treatmentVariant?.variantConfiguration.configurationBundle?.bundleArn ?? '')} @ ${shortVersion(treatmentVariant?.variantConfiguration.configurationBundle?.bundleVersion ?? '')}`} + @@ -523,7 +585,20 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr {confirmingStop && ( - {'Stop this AB test permanently? This cannot be undone. (Y/n)'} + { + '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)' + } )} diff --git a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx index 3cfad07b2..b8313075d 100644 --- a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx @@ -5,11 +5,14 @@ import { useCreateABTest, useExistingABTestNames } from '../../hooks/useCreateAB 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 }; @@ -23,7 +26,7 @@ interface AddABTestFlowProps { } export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddABTestFlowProps) { - const { createABTest, reset: resetCreate } = useCreateABTest(); + const { createABTest, createTargetBasedABTest, reset: resetCreate } = useCreateABTest(); const { names: existingNames } = useExistingABTestNames(); const [flow, setFlow] = useState({ name: 'create-wizard' }); @@ -32,6 +35,9 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD 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); @@ -64,13 +70,44 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD 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(); @@ -108,6 +145,35 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD 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, @@ -131,9 +197,13 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD setFlow({ name: 'error', message: result.error }); }); }, - [createABTest] + [createABTest, createTargetBasedABTest] ); + const handleSwitchToTargetBased = useCallback(() => { + setFlow({ name: 'target-wizard' }); + }, []); + const handleCreateBundle = useCallback(() => { setFlow({ name: 'create-bundle' }); }, []); @@ -149,6 +219,20 @@ export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onD ); } + if (flow.name === 'target-wizard') { + return ( + + ); + } + if (flow.name === 'create-wizard') { return ( ); } diff --git a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx index e6c909270..c8eba3b66 100644 --- a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx +++ b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx @@ -5,11 +5,11 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import type { VersionLoadState } from './VariantConfigForm'; import { VariantConfigForm } from './VariantConfigForm'; -import type { AddABTestConfig } from './types'; +import type { AddABTestConfig, TargetInfo } from './types'; import { AB_TEST_STEP_LABELS } from './types'; import { useAddABTestWizard } from './useAddABTestWizard'; -import { Text } from 'ink'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; function formatVersionDate(value: string): string { const n = Number(value); @@ -21,6 +21,39 @@ function formatVersionDate(value: string): string { 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; @@ -31,6 +64,14 @@ interface AddABTestScreenProps { 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({ @@ -43,6 +84,10 @@ export function AddABTestScreen({ onlineEvalConfigs, fetchBundleVersions, onCreateBundle, + runtimes, + httpGatewayDetails, + onlineEvalConfigDetails = [], + onSwitchToTargetBased, }: AddABTestScreenProps) { const wizard = useAddABTestWizard(); @@ -63,12 +108,16 @@ export function AddABTestScreen({ ); const gatewayItems: SelectableItem[] = useMemo(() => { - const items: SelectableItem[] = [ - { id: '__create_new__', title: 'Create new HTTP gateway', description: 'Auto-create for this AB test' }, - ]; + 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]); @@ -114,40 +163,209 @@ export function AddABTestScreen({ [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(''); + + // Reset sub-flow state when entering a target step + useEffect(() => { + if (wizard.step === 'controlTarget') { + setControlSubFlow('pick'); + setControlNewRuntime(''); + } + }, [wizard.step]); + + useEffect(() => { + if (wizard.step === 'treatmentTarget') { + setTreatmentSubFlow('pick'); + setTreatmentNewRuntime(''); + } + }, [wizard.step]); + // 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'; - // TODO(post-preview): Re-enable maxDuration step once configurable duration is launched. - // const isMaxDurationStep = wizard.step === 'maxDuration'; + 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). - // The gateway step is skipped when there are no existing gateways — the default - // config already sets gatewayChoice to 'create-new'. - // Track gateway choice type in a ref so the skip check always has the latest value, - // even before React re-renders after setGateway updates state. const gatewayChoiceTypeRef = React.useRef(wizard.config.gatewayChoice.type); - const shouldSkipStep = useCallback((s: string) => { - // Agent selection is only needed when auto-creating a gateway (to set the runtime target). - // When using an existing gateway, the runtime is already configured. - if (s === 'agent' && gatewayChoiceTypeRef.current !== 'create-new') return true; - // TODO(post-preview): Re-enable maxDuration step once configurable duration is launched. - // For public preview, a 14-day default is enforced server-side. - if (s === 'maxDuration') return true; - return false; - }, []); + 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 && t.name === excludeTarget.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 && 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: '── 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), @@ -158,17 +376,17 @@ export function AddABTestScreen({ const gatewayNav = useListNavigation({ items: gatewayItems, onSelect: item => { - const choice = - item.id === '__create_new__' - ? ({ type: 'create-new' } as const) - : ({ type: 'existing-http', name: item.id } as const); - // Update ref before setGateway so the skip check sees the new choice - // when advance() runs synchronously in the same call. + if (item.id === '__create__') { + setGatewayCreateMode(true); + return; + } + const choice = { type: 'existing-http', name: item.id } as const; gatewayChoiceTypeRef.current = choice.type; - wizard.setGateway(choice); + wizard.setGatewayWithName(item.id, false); }, onExit: () => wizard.goBack(), - isActive: isGatewayStep, + isActive: isGatewayStep && !gatewayCreateMode, + isDisabled: item => item.disabled === true, }); const onlineEvalNav = useListNavigation({ @@ -178,6 +396,191 @@ export function AddABTestScreen({ 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 + useEffect(() => { + if (wizard.step === 'evalSelect') { + setEvalSelectPhase('controlEval'); + setSelectedControlEval(''); + } + }, [wizard.step]); + + // 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'), @@ -193,7 +596,16 @@ export function AddABTestScreen({ }); // Help text - const isSelectStep = isAgentStep || isGatewayStep || isOnlineEvalStep || isEnableStep; + const isSelectStep = + isModeStep || + isAgentStep || + (isGatewayStep && !gatewayCreateMode) || + isOnlineEvalStep || + isEnableStep || + isControlTargetStep || + isTreatmentTargetStep || + isEvalPathStep || + isEvalSelectStep; const helpText = isSelectStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep @@ -206,9 +618,30 @@ export function AddABTestScreen({ 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 && ( } - {isGatewayStep && ( + {/* ── Step 4: Gateway selection ──────────────────────────── */} + {isGatewayStep && !gatewayCreateMode && ( )} + {isGatewayStep && gatewayCreateMode && ( + { + gatewayChoiceTypeRef.current = 'create-new'; + wizard.setGatewayWithName(name, true); + setGatewayCreateMode(false); + }} + onCancel={() => setGatewayCreateMode(false)} + /> + )} {isVariantsStep && ( )} + {/* ── 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 ? ( )} 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/__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/types.ts b/src/cli/tui/screens/ab-test/types.ts index 211711cf1..977a2ca07 100644 --- a/src/cli/tui/screens/ab-test/types.ts +++ b/src/cli/tui/screens/ab-test/types.ts @@ -2,12 +2,22 @@ // 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' @@ -15,27 +25,63 @@ export type AddABTestStep = 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', diff --git a/src/cli/tui/screens/ab-test/useAddABTestWizard.ts b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts index 95fef2402..bb4fef0ad 100644 --- a/src/cli/tui/screens/ab-test/useAddABTestWizard.ts +++ b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts @@ -1,8 +1,9 @@ import type { VariantConfig } from './VariantConfigForm'; -import type { AddABTestConfig, AddABTestStep, GatewayChoice } from './types'; +import type { ABTestMode, AddABTestConfig, AddABTestStep, GatewayChoice, TargetInfo } from './types'; import { useCallback, useRef, useState } from 'react'; -const ALL_STEPS: AddABTestStep[] = [ +const CONFIG_BUNDLE_STEPS: AddABTestStep[] = [ + 'mode', 'name', 'description', 'gateway', @@ -14,8 +15,22 @@ const ALL_STEPS: AddABTestStep[] = [ '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: '', @@ -26,6 +41,24 @@ function getDefaultConfig(): AddABTestConfig { 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, }; @@ -35,36 +68,42 @@ export type StepSkipCheck = (step: AddABTestStep) => boolean; export function useAddABTestWizard() { const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('name'); + const [step, setStep] = useState('mode'); const skipCheckRef = useRef(() => false); - const currentIndex = ALL_STEPS.indexOf(step); + const getSteps = useCallback((): AddABTestStep[] => { + return config.mode === 'target-based' ? TARGET_BASED_STEPS : CONFIG_BUNDLE_STEPS; + }, [config.mode]); + + const currentIndex = getSteps().indexOf(step); - /** Register a callback that returns true for steps that should be skipped. */ const setSkipCheck = useCallback((check: StepSkipCheck) => { skipCheckRef.current = check; }, []); const goBack = useCallback(() => { - // Walk backwards, skipping auto-skippable steps + const steps = getSteps(); for (let i = currentIndex - 1; i >= 0; i--) { - if (!skipCheckRef.current(ALL_STEPS[i]!)) { - setStep(ALL_STEPS[i]!); + if (!skipCheckRef.current(steps[i]!)) { + setStep(steps[i]!); return; } } - }, [currentIndex]); - - const nextStep = useCallback((currentStep: AddABTestStep): AddABTestStep | undefined => { - const idx = ALL_STEPS.indexOf(currentStep); - // Walk forwards, skipping auto-skippable steps - for (let i = idx + 1; i < ALL_STEPS.length; i++) { - if (!skipCheckRef.current(ALL_STEPS[i]!)) { - return ALL_STEPS[i]!; + }, [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; - }, []); + return undefined; + }, + [getSteps] + ); const advance = useCallback( (from: AddABTestStep) => { @@ -74,6 +113,14 @@ export function useAddABTestWizard() { [nextStep] ); + const setMode = useCallback( + (mode: ABTestMode) => { + setConfig(c => ({ ...c, mode })); + advance('mode'); + }, + [advance] + ); + const setName = useCallback( (name: string) => { setConfig(c => ({ ...c, name })); @@ -100,7 +147,28 @@ export function useAddABTestWizard() { const setGateway = useCallback( (gatewayChoice: GatewayChoice) => { - setConfig(c => ({ ...c, 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] @@ -129,6 +197,83 @@ export function useAddABTestWizard() { [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 })); @@ -147,22 +292,31 @@ export function useAddABTestWizard() { const reset = useCallback(() => { setConfig(getDefaultConfig()); - setStep('name'); + setStep('mode'); }, []); return { config, step, - steps: ALL_STEPS, + 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/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index 60cac5c50..7d7f8699f 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -2,13 +2,13 @@ import { APP_DIR, ConfigIO, NoProjectError, findConfigRoot, setEnvVar } from '.. 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, mapModelProviderToIdentityProviders, writeAgentToProject, } from '../../../operations/agent/generate'; -import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; import { executeImportAgent } from '../../../operations/agent/import'; import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from '../../../primitives/auth-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index b2ebf4bda..b27b3e528 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -287,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 ?? []; @@ -324,15 +330,15 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // 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 onlineEvalSpecs = ctx.projectSpec.onlineEvalConfigs ?? []; + const onlineEvalFullSpecs = ctx.projectSpec.onlineEvalConfigs ?? []; const deployedOnlineEvalConfigs = deployedState.targets?.[target.name]?.resources?.onlineEvalConfigs ?? {}; const previouslyDeployedOnlineEvals = existingState?.targets?.[target.name]?.resources?.onlineEvalConfigs ?? {}; - const newOnlineEvalSpecs = onlineEvalSpecs.filter(c => !previouslyDeployedOnlineEvals[c.name]); - if (newOnlineEvalSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { + 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: newOnlineEvalSpecs, + onlineEvalConfigs: newOnlineEvalFullSpecs, deployedOnlineEvalConfigs, }); diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 679504108..1b764ea80 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -160,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' | 'configBundle'; +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' }, @@ -169,7 +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' }, + { + 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/online-eval/AddOnlineEvalFlow.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx index dbbd1f772..243eba4e7 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx @@ -6,13 +6,14 @@ import { getErrorMessage } from '../../../errors'; 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) }); } @@ -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..70809beff 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 type { AddOnlineEvalConfig, AddOnlineEvalStep, RuntimeEndpointEntry } 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/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index 7b34770dc..4464b1af2 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -416,7 +416,12 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results const toolResult = result.result?.toolDescriptionRecommendationResult; return ( - + ✓ Recommendation complete diff --git a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx index c1bcbe8e8..a1903f7d0 100644 --- a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx +++ b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx @@ -286,7 +286,12 @@ export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) 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 index 6f9ea8df0..38c03c150 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -862,7 +862,12 @@ function ResultsView({ result, savedFilePath, onRunAnother, onExit }: ResultsVie }, [result.results, summaries]); return ( - + ✓ Batch evaluation complete diff --git a/src/cli/tui/utils/commands.ts b/src/cli/tui/utils/commands.ts index c9d99272e..912c6a7f6 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -12,7 +12,7 @@ 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). diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 53407cc25..67ef0e73f 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -54,6 +54,10 @@ 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 @@ -401,6 +405,41 @@ export const AgentCoreProjectSpecSchema = z 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}"`, + }); } } } diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index b22478bb1..a37469799 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -166,6 +166,10 @@ 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; @@ -193,6 +197,8 @@ export const ABTestDeployedStateSchema = z.object({ 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; diff --git a/src/schema/schemas/primitives/ab-test.ts b/src/schema/schemas/primitives/ab-test.ts index db4b9e211..489987a20 100644 --- a/src/schema/schemas/primitives/ab-test.ts +++ b/src/schema/schemas/primitives/ab-test.ts @@ -15,10 +15,16 @@ export const ABTestNameSchema = z 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), @@ -26,10 +32,29 @@ export const ConfigurationBundleRefSchema = z.object({ export type ConfigurationBundleRef = z.infer; -export const VariantConfigurationSchema = z.object({ +// ── 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.undefined().optional(), +}); + +const TargetVariantConfigSchema = z.object({ + configurationBundle: z.undefined().optional(), + target: TargetRefSchema, }); +export const VariantConfigurationSchema = z.union([ConfigBundleVariantConfigSchema, TargetVariantConfigSchema]); + export type VariantConfiguration = z.infer; export const ABTestVariantSchema = z.object({ @@ -40,12 +65,34 @@ export const ABTestVariantSchema = z.object({ export type ABTestVariant = z.infer; -export const ABTestEvaluationConfigSchema = z.object({ +// ── 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), }); @@ -56,17 +103,22 @@ export const TrafficAllocationConfigSchema = z.object({ 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 => { @@ -78,6 +130,18 @@ export const ABTestSchema = z .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/http-gateway.ts b/src/schema/schemas/primitives/http-gateway.ts index b502882c4..f40505b5f 100644 --- a/src/schema/schemas/primitives/http-gateway.ts +++ b/src/schema/schemas/primitives/http-gateway.ts @@ -13,6 +13,17 @@ export const HttpGatewayNameSchema = z 'Must begin with a letter and contain only alphanumeric characters and hyphens (max 48 chars)' ); +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 */ @@ -23,6 +34,8 @@ export const HttpGatewaySchema = z 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(); 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) */ From ada2c290366aaacdc6ab5ab6d16235671bd62c7e Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:29:34 -0400 Subject: [PATCH 56/64] test: add integ and e2e tests for recommendations (#106) * test: add integ and e2e tests for recommendations Integ tests (12) cover CLI validation for run recommendation: required flags, system-prompt/tool-description input validation, config bundle source, spans file validation, and lookback/session options. E2E tests (8) cover recommendation API lifecycle: start system-prompt and tool-description recommendations, get, delete (stop-via-delete), verify 404 after delete, inline session spans, and error cases. * remove API-level e2e tests (CLI e2e lives in PR #107) * fix: add error message assertions to required flag tests Assert JSON error content (--runtime, --evaluator, --type) instead of only checking exitCode, so tests fail meaningfully on crashes. --- integ-tests/recommendation.test.ts | 290 +++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 integ-tests/recommendation.test.ts 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'); + }); + }); +}); From f8afabe085ab2353309c0f09dfd085bf9e364fba Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:30:35 -0400 Subject: [PATCH 57/64] test: add integ and e2e tests for config bundles, batch eval, recommendations (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add integ and e2e tests for config bundles, batch eval, recommendations Integ tests (48): config bundle add/remove lifecycle, evaluator/online-eval lifecycle, batch-evaluation CLI validation, ground truth parsing, recommendation CLI validation. E2E test (1 file, 17 tests): full CLI lifecycle — create project → add config bundle → add evaluator → deploy → invoke → config-bundle versions/diff → run batch-evaluation → run eval → run recommendation (system-prompt, tool-description, config-bundle source) → remove + reconcile. * refactor: keep only e2e test in this PR (integ tests live in separate PRs) * fix: address PR review — stronger e2e assertions and real session IDs - Use real session ID from invoke for ground truth (not hardcoded) - Assert diff array is non-empty, not just property existence - Assert batch eval status is not FAILED - Assert recommendation result is non-empty - Add comment explaining retry rationale for on-demand eval - Reduce excessive retry count (18→10) for on-demand eval --- e2e-tests/config-bundle-eval-rec.test.ts | 633 +++++++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 e2e-tests/config-bundle-eval-rec.test.ts 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..8151ac586 --- /dev/null +++ b/e2e-tests/config-bundle-eval-rec.test.ts @@ -0,0 +1,633 @@ +/** + * 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', '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 + ); + + // ════════════════════════════════════════════════════════════════════════ + // 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 === 'configBundle' && 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('batchEvaluateId'); + 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', + '--lookback', + '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', + 'search:Searches the web for information', + '--tools', + 'calculator:Performs mathematical calculations', + '--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 + ); +}); From ed6d7aecadc3e1c1fe64f05de35d44e564b6b753 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:33:15 -0400 Subject: [PATCH 58/64] test: add integ and e2e tests for config bundles (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add integ and e2e tests for config bundles Integ tests cover add/remove lifecycle, CLI validation, components-file support, duplicate rejection, placeholder keys, and multi-bundle coexistence. E2E tests cover full API lifecycle (create, get, update, list versions, branch filtering, diff, delete) against the real control plane. * fix: remove hardcoded account ID from e2e config bundle tests Resolve account ID dynamically from AWS_ACCOUNT_ID env var or aws sts get-caller-identity, matching the pattern in e2e-helper.ts. * remove API-level e2e tests (CLI e2e lives in PR #107) * fix: address PR review — extract shared helpers, fix afterAll cleanup Move runSuccess/runFailure to shared test-utils to prevent duplication. Fix afterAll to defensively clean all bundleNames on test failure. --- integ-tests/add-remove-config-bundle.test.ts | 312 +++++++++++++++++++ src/test-utils/index.ts | 19 ++ 2 files changed, 331 insertions(+) create mode 100644 integ-tests/add-remove-config-bundle.test.ts 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..a7d68ccf4 --- /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/src/test-utils/index.ts b/src/test-utils/index.ts index ff127a35e..105ecf061 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -2,6 +2,8 @@ * 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 { exists } from './fs-helpers.js'; @@ -9,6 +11,23 @@ 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. */ From 65031363c15080fd11b7550a4203eb231b51d41d Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:42:15 -0400 Subject: [PATCH 59/64] fix: wire config bundle through TUI add-agent and create flows (#153) The configBundle advanced setting was selectable in the TUI but never propagated to the output config, so it silently did nothing. - AddAgentScreen: set withConfigBundle on byoConfig when selected in advanced settings, clear it when deselected, pass it through both create and BYO complete handlers, show in confirm review - GenerateWizardUI: show config bundle in confirm summary - useCreateFlow: pass withConfigBundle to GenerateConfig and call createConfigBundleForAgent after agent is written --- src/cli/tui/screens/agent/AddAgentScreen.tsx | 7 +++++++ src/cli/tui/screens/create/useCreateFlow.ts | 7 +++++++ src/cli/tui/screens/generate/GenerateWizardUI.tsx | 6 ++++++ 3 files changed, 20 insertions(+) diff --git a/src/cli/tui/screens/agent/AddAgentScreen.tsx b/src/cli/tui/screens/agent/AddAgentScreen.tsx index e99386055..6aac2fcae 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,7 @@ 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/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 04c003ad0..8f0ecb5fd 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -9,6 +9,7 @@ import { mapModelProviderToIdentityProviders, writeAgentToProject, } from '../../../operations/agent/generate'; +import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; import { executeImportAgent } from '../../../operations/agent/import'; import { createManagedOAuthCredential } from '../../../primitives/auth-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; @@ -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/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) + + )} ); From 71d8d93b8125e8681a372f809a36ad50872b97ee Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:47:08 -0400 Subject: [PATCH 60/64] fix: validate batch eval name and fix ground truth in legacy fallback (#161) 1. Validate batch eval name against API pattern [a-zA-Z][a-zA-Z0-9_]{0,47} before sending. The API returns a misleading "Resource identifier cannot be empty" for invalid names (e.g. hyphens). The CLI now gives a clear error message with the exact constraints. 2. Fix ground truth in legacy fallback: was sending sessionMetadata at top level instead of wrapping in evaluationMetadata. Both old and new API models expect evaluationMetadata.sessionMetadata. --- src/cli/aws/agentcore-batch-evaluation.ts | 5 ++--- src/cli/operations/eval/run-batch-evaluation.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index e8710162e..1e3d88192 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -323,9 +323,8 @@ function toLegacyStartBody(options: StartBatchEvaluationOptions): string { }, }; - const sessionMetadata = options.evaluationMetadata?.sessionMetadata; - if (sessionMetadata && sessionMetadata.length > 0) { - body.sessionMetadata = sessionMetadata; + if (options.evaluationMetadata) { + body.evaluationMetadata = options.evaluationMetadata; } if (options.clientToken) body.clientToken = options.clientToken; diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index eaa1f5671..efd189368 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -147,7 +147,16 @@ export async function runBatchEvaluationCommand( // 3. Start the batch evaluation logger?.startStep('Start batch evaluation'); - const evalName = options.name ?? `${projectSpec.name}_${agent}_${Date.now()}`; + const rawName = options.name ?? `${projectSpec.name}_${agent}_${Date.now()}`; + const evalName = rawName.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 48); + if (!/^[a-zA-Z]/.test(evalName)) { + return { + success: false, + error: `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${rawName}"`, + results: [], + logFilePath: logger?.logFilePath, + }; + } onProgress?.('starting', `Starting batch evaluation "${evalName}"...`); From 78d5ac1abadfc3a406c13f02626ebb578b0483e2 Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Thu, 30 Apr 2026 11:56:26 -0400 Subject: [PATCH 61/64] fix: fixes for target-based AB testing, SDK wheel update, and test coverage. (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: extract deleteHttpGatewayWithTargets shared helper Extract gateway deletion logic (targets → gateway → role) into shared deleteHttpGatewayWithTargets(). Add deleteOrphanedHttpGateways() for deploy reconciliation. Teardown uses shared helper. Target failures are best-effort (warn, continue). * chore: update bundled SDK wheel to bedrock-agentcore 1.6.4 Replace the dev pre-release wheel (1.6.0.dev20260413) with the current SDK release (1.6.4) from bedrock-agentcore-sdk-python-private. * feat: add [tool.uv.sources] vendored wheel support to agent templates - render.ts: copy .whl files verbatim instead of through Handlebars - BaseRenderer: copy bundled SDK wheel into scaffolded project's wheels/ - Add [tool.uv.sources] block to all 8 agent pyproject.toml templates pointing at wheels/bedrock_agentcore-1.6.4-py3-none-any.whl - Dockerfile: conditionally COPY wheels/ for Container builds - Tests: .whl binary handling, wheel copy verification, updated snapshots * test: add e2e and integ tests for AB tests, gateways, and online evals Integ tests (17 passing): - Target-based AB test CLI flags (11 tests) - Online eval with endpoint field (6 tests) E2E tests (require AWS ap-southeast-2): - Target-based AB test full lifecycle - Config-bundle AB test full lifecycle - HTTP gateway with targets lifecycle * fix: remove gateway trace delivery, add runtime experiment span debug check - Remove gateway trace delivery setup from deploy - Remove Gateway Trace Delivery and Gateway Spans from debug panel - Add Runtime Experiment Spans check to debug panel (queries aws/spans for abTestArn) * fix: improve runtime experiment span debug checks with per-variant filtering and service.name - Split single experiment span check into per-variant (C, T1) checks - Filter baseline runtime spans by service.name from deployed state instead of gen_ai_agent - Show targeted warnings when one variant has spans but the other doesn't * Revert "feat: add [tool.uv.sources] vendored wheel support to agent templates" This reverts commit 458177299237a50e9bc6eb4aada607d18dced3f2. --- e2e-tests/ab-test-config-bundle.test.ts | 211 ++++++++ e2e-tests/ab-test-target-based.test.ts | 301 ++++++++++++ e2e-tests/http-gateway-targets.test.ts | 228 +++++++++ .../add-remove-ab-test-target-based.test.ts | 461 ++++++++++++++++++ .../add-remove-online-eval-endpoint.test.ts | 199 ++++++++ ...entcore-1.6.0.dev20260413-py3-none-any.whl | Bin 199052 -> 0 bytes .../bedrock_agentcore-1.6.4-py3-none-any.whl | Bin 0 -> 224156 bytes .../deploy/post-deploy-http-gateways.ts | 270 ++++------ src/cli/operations/deploy/teardown.ts | 39 +- .../screens/ab-test/ABTestDetailScreen.tsx | 146 +++--- 10 files changed, 1569 insertions(+), 286 deletions(-) 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/http-gateway-targets.test.ts create mode 100644 integ-tests/add-remove-ab-test-target-based.test.ts create mode 100644 integ-tests/add-remove-online-eval-endpoint.test.ts delete mode 100644 src/assets/wheels/bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl create mode 100644 src/assets/wheels/bedrock_agentcore-1.6.4-py3-none-any.whl 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..9c18b2f31 --- /dev/null +++ b/e2e-tests/ab-test-config-bundle.test.ts @@ -0,0 +1,211 @@ +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']); + if (result.exitCode !== 0) { + console.log('Initial deploy stdout:', result.stdout); + console.log('Initial deploy stderr:', result.stderr); + } + 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 () => { + // Config bundles reference ARNs from deployed resources. + // Use placeholder bundle ARNs — the deploy step will validate or create them. + const controlBundle = `arn:aws:bedrock-agentcore:ap-southeast-2:998846730471:config-bundle/control-v1`; + const treatmentBundle = `arn:aws:bedrock-agentcore:ap-southeast-2:998846730471:config-bundle/treatment-v1`; + + const result = await run([ + 'add', + 'ab-test', + '--mode', + 'config-bundle', + '--name', + abTestName, + '--runtime', + agentName, + '--control-bundle', + controlBundle, + '--control-version', + 'v1', + '--treatment-bundle', + treatmentBundle, + '--treatment-version', + 'v1', + '--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..ac687e4fb --- /dev/null +++ b/e2e-tests/ab-test-target-based.test.ts @@ -0,0 +1,301 @@ +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']); + 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).toBe(true); + }, + 2, + 30000 + ); + }, + 600000 + ); + + 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 }[]; + }; + 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'); + + // Gateway should be deployed + const gateway = json.resources.find(r => r.resourceType === 'http-gateway' && r.name === `${abTestName}-gw`); + expect(gateway, 'HTTP gateway should appear in status').toBeDefined(); + }, + 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.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/http-gateway-targets.test.ts b/e2e-tests/http-gateway-targets.test.ts new file mode 100644 index 000000000..c2bef22fb --- /dev/null +++ b/e2e-tests/http-gateway-targets.test.ts @@ -0,0 +1,228 @@ +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: HTTP gateway with targets lifecycle', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eGwTgt${String(Date.now()).slice(-8)}`; + const gatewayName = 'e2e-target-gw'; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-gw-targets-${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, staging)', + 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 HTTP gateway with name', + async () => { + const result = await run(['add', 'gateway', '--name', gatewayName, '--json']); + expect(result.exitCode, `Add gateway failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'adds gateway targets for prod and staging endpoints', + async () => { + let result = await run([ + 'add', + 'gateway-target', + '--name', + `${agentName}-prod`, + '--type', + 'mcp-server', + '--endpoint', + 'https://placeholder-prod.example.com', + '--gateway', + gatewayName, + '--json', + ]); + expect(result.exitCode, `Add prod target failed: ${result.stdout}`).toBe(0); + + result = await run([ + 'add', + 'gateway-target', + '--name', + `${agentName}-staging`, + '--type', + 'mcp-server', + '--endpoint', + 'https://placeholder-staging.example.com', + '--gateway', + gatewayName, + '--json', + ]); + expect(result.exitCode, `Add staging target failed: ${result.stdout}`).toBe(0); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'deploys project with gateway and targets', + 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).toBe(true); + }, + 2, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'status shows gateway 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; identifier?: 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'); + }, + 3, + 15000 + ); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'invokes the deployed agent directly', + 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 gateway targets', + async () => { + let result = await run(['remove', 'gateway-target', '--name', `${agentName}-prod`, '--json']); + expect(result.exitCode, `Remove prod target failed: ${result.stderr}`).toBe(0); + + result = await run(['remove', 'gateway-target', '--name', `${agentName}-staging`, '--json']); + expect(result.exitCode, `Remove staging target failed: ${result.stderr}`).toBe(0); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'removes gateway', + async () => { + const result = await run(['remove', 'gateway', '--name', gatewayName, '--json']); + expect(result.exitCode, `Remove gateway failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 60000 + ); +}); 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-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/src/assets/wheels/bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl b/src/assets/wheels/bedrock_agentcore-1.6.0.dev20260413-py3-none-any.whl deleted file mode 100644 index afd3ac66abd7b0872d85fa97511f71db5f2b541a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 199052 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

    pjm81W^ONoS2K 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 z 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 literal 0 HcmV?d00001 diff --git a/src/cli/operations/deploy/post-deploy-http-gateways.ts b/src/cli/operations/deploy/post-deploy-http-gateways.ts index 898b12488..edaf852ec 100644 --- a/src/cli/operations/deploy/post-deploy-http-gateways.ts +++ b/src/cli/operations/deploy/post-deploy-http-gateways.ts @@ -11,13 +11,6 @@ import { waitForGatewayReady, waitForTargetReady, } from '../../aws/agentcore-http-gateways'; -import { - CloudWatchLogsClient, - CreateDeliveryCommand, - DescribeDeliverySourcesCommand, - PutDeliveryDestinationCommand, - PutDeliverySourceCommand, -} from '@aws-sdk/client-cloudwatch-logs'; import { CreateRoleCommand, DeleteRoleCommand, @@ -81,8 +74,6 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom // If someone has "httpGateways": null in their JSON, it passes through as null. const httpGatewaySpecs = projectSpec.httpGateways ?? []; - const specGatewayNames = new Set(httpGatewaySpecs.map(gw => gw.name)); - // Create or skip gateways from the spec for (const gwSpec of httpGatewaySpecs) { let resolvedRoleArn: string | undefined; @@ -91,8 +82,7 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom const existingGateway = existingHttpGateways?.[gwSpec.name]; if (existingGateway) { - // Already deployed — ensure trace delivery is enabled (may have failed on initial deploy) - await ensureTraceDelivery({ region, gatewayName: gwSpec.name, gatewayArn: existingGateway.gatewayArn }); + // Already deployed // Create or update targets from httpGateways[].targets (for target-based AB testing) if (gwSpec.targets && gwSpec.targets.length > 0) { @@ -185,8 +175,6 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom 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.` ); - // Ensure trace delivery is enabled (may have failed on initial deploy) - await ensureTraceDelivery({ region, gatewayName: gwSpec.name, gatewayArn: existingByName.gatewayArn }); httpGateways[gwSpec.name] = { gatewayId: existingByName.gatewayId, gatewayArn: existingByName.gatewayArn, @@ -288,46 +276,6 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom continue; } - // Enable gateway trace delivery to aws/spans (required for online eval + AB test aggregation). - // Without this, the AB test aggregation pipeline won't receive gateway spans. - try { - await enableGatewayTraceDelivery({ - region, - gatewayName: gwSpec.name, - gatewayArn: createResult.gatewayArn, - }); - } catch (traceErr) { - // Rollback: delete target, then gateway, then role - 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 cleanup - } - if (roleCreatedByCli && resolvedRoleArn) { - try { - await deleteHttpGatewayRole(region, resolvedRoleArn); - } catch { - // Best-effort role cleanup - } - } - - results.push({ - gatewayName: gwSpec.name, - status: 'error', - error: - `Trace delivery failed, gateway rolled back: ${traceErr instanceof Error ? traceErr.message : String(traceErr)}. ` + - `Enable manually with: aws logs put-delivery-source --name gateway-traces-${gwSpec.name} --resource-arn ${createResult.gatewayArn} --log-type TRACES --region ${region}`, - }); - 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) { @@ -389,63 +337,8 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom } } - // Delete orphaned HTTP gateways (in deployed-state but removed from spec) - if (existingHttpGateways) { - for (const [gwName, gwState] of Object.entries(existingHttpGateways)) { - if (!specGatewayNames.has(gwName)) { - try { - // Delete all targets before deleting the gateway. - // Use known targetId first; fall back to listing all targets. - const targetIds: string[] = []; - if (gwState.targetId) { - targetIds.push(gwState.targetId); - } else { - try { - const targets = await listHttpGatewayTargets({ - region, - gatewayId: gwState.gatewayId, - maxResults: 100, - }); - targetIds.push(...targets.targets.map(t => t.targetId)); - } catch { - // Best-effort — proceed with gateway deletion anyway - } - } - - for (const targetId of targetIds) { - await deleteHttpGatewayTarget({ - region, - gatewayId: gwState.gatewayId, - targetId, - }); - } - - // Delete gateway after all targets are fully deleted - const deleteResult = await deleteHttpGateway({ - region, - gatewayId: gwState.gatewayId, - }); - - // Clean up the auto-created IAM role only if gateway deletion succeeded - if (deleteResult.success && gwState.roleCreatedByCli && gwState.roleArn) { - await deleteHttpGatewayRole(region, gwState.roleArn); - } - - results.push({ - gatewayName: gwName, - status: deleteResult.success ? 'deleted' : 'error', - error: deleteResult.error, - }); - } catch (err) { - results.push({ - gatewayName: gwName, - 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, @@ -455,99 +348,118 @@ export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Prom } // ============================================================================ -// Gateway Trace Delivery +// Shared Gateway Deletion // ============================================================================ /** - * Enable CloudWatch log delivery for gateway traces. - * - * Sets up the full delivery chain: source → destination → delivery. - * Required for online eval + AB test aggregation pipeline. + * Delete an HTTP gateway and all its targets. Best-effort — target failures + * are warned but don't prevent gateway deletion attempt. * - * 1. PutDeliverySource — register gateway as TRACES source - * 2. PutDeliveryDestination — create XRAY destination - * 3. CreateDelivery — connect source to destination + * Order: targets → gateway → role */ -async function enableGatewayTraceDelivery(options: { +export async function deleteHttpGatewayWithTargets(options: { region: string; + gatewayId: string; gatewayName: string; - gatewayArn: string; -}): Promise { - const { region, gatewayName, gatewayArn } = options; - const credentials = getCredentialProvider(); - const logsClient = new CloudWatchLogsClient({ region, credentials }); - - const sourceName = `agentcore-gw-traces-${gatewayName}`; - const destName = `agentcore-gw-dest-${gatewayName}`; - - // 1. Register gateway as trace source - await logsClient.send( - new PutDeliverySourceCommand({ - name: sourceName, - resourceArn: gatewayArn, - logType: 'TRACES', - }) - ); + 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 + } - // 2. Create XRAY destination - const destResult = await logsClient.send( - new PutDeliveryDestinationCommand({ - name: destName, - deliveryDestinationType: 'XRAY', - }) - ); + 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 destArn = destResult.deliveryDestination?.arn; - if (!destArn) { - throw new Error('PutDeliveryDestination returned no ARN'); + const deleteResult = await deleteHttpGateway({ region, gatewayId }); + if (!deleteResult.success) { + return { success: false, error: deleteResult.error }; } - // 3. Connect source to destination (may already exist on redeploy) - try { - await logsClient.send( - new CreateDeliveryCommand({ - deliverySourceName: sourceName, - deliveryDestinationArn: destArn, - }) - ); - } catch (err) { - const errName = (err as { name?: string }).name; - if (errName !== 'ConflictException') throw err; - // Delivery already exists — idempotent + if (roleCreatedByCli && roleArn) { + try { + await deleteHttpGatewayRole(region, roleArn); + } catch { + // Best-effort role cleanup + } } - // Gateway trace delivery enabled + return { success: true }; } /** - * Check if trace delivery is already enabled for a gateway. - * If not, enable it. Failures are logged as warnings (non-fatal for existing gateways). + * Delete orphaned HTTP gateways (in deployed-state but removed from spec). + * Call before setupHttpGateways. */ -async function ensureTraceDelivery(options: { +export async function deleteOrphanedHttpGateways(options: { region: string; - gatewayName: string; - gatewayArn: string; -}): Promise { - const { region, gatewayName, gatewayArn } = options; - const credentials = getCredentialProvider(); - const logsClient = new CloudWatchLogsClient({ region, credentials }); + projectSpec: AgentCoreProjectSpec; + existingHttpGateways?: Record; +}): Promise<{ results: HttpGatewaySetupResult[]; hasErrors: boolean }> { + const { region, projectSpec, existingHttpGateways } = options; + if (!existingHttpGateways) return { results: [], hasErrors: false }; - try { - const sources = await logsClient.send(new DescribeDeliverySourcesCommand({})); - const hasSource = (sources.deliverySources ?? []).some( - s => s.resourceArns?.some(a => a.endsWith(`/${gatewayArn.split('/').pop()!}`)) && s.logType === 'TRACES' - ); + const specGatewayNames = new Set(projectSpec.httpGateways.map(g => g.name)); + const results: HttpGatewaySetupResult[] = []; - if (!hasSource) { - await enableGatewayTraceDelivery({ region, gatewayName, gatewayArn }); + 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), + }); + } } - } catch (err) { - console.warn( - `Warning: Could not verify/enable trace delivery for gateway "${gatewayName}": ${err instanceof Error ? err.message : String(err)}` - ); } + + return { + results, + hasErrors: results.some(r => r.status === 'error'), + }; } +// ============================================================================ +// Gateway Trace Delivery +// ============================================================================ + // ============================================================================ // Helpers // ============================================================================ diff --git a/src/cli/operations/deploy/teardown.ts b/src/cli/operations/deploy/teardown.ts index 7aaf59aaa..9fd2e9238 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -1,10 +1,9 @@ import { CONFIG_DIR, ConfigIO } from '../../../lib'; import type { AwsDeploymentTarget } from '../../../schema'; import { withTargetRegion } from '../../aws'; -import { deleteHttpGateway, deleteHttpGatewayTarget } from '../../aws/agentcore-http-gateways'; import { CdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib'; import { type DiscoveredStack, findStack } from '../../cloudformation/stack-discovery'; -import { deleteHttpGatewayRole } from './post-deploy-http-gateways'; +import { deleteHttpGatewayWithTargets } from './post-deploy-http-gateways'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -137,32 +136,18 @@ export async function performStackTeardown(targetName: string): Promise v.name); + let serviceNamePrefix: string | undefined; try { - const [sources, deliveries] = await Promise.all([ - paginateDeliverySources(logsClient), - paginateDeliveries(logsClient), - ]); + 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 + } - const traceSources = sources.filter(s => s.logType === 'TRACES'); + 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 source = traceSources.find(s => s.resourceArns?.some(a => a.includes(gatewayId))); - const delivery = source ? deliveries.find(d => d.deliverySourceName === source.name) : undefined; + const hasRuntimeSpans = (allRuntimeSpans.events?.length ?? 0) > 0; + const totalExperimentSpans = variantSpanResults.reduce((sum, r) => sum + (r.events?.length ?? 0), 0); - const hasSource = !!source; - const hasDelivery = !!delivery; + 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 (hasSource && hasDelivery) { - results.push({ - label: 'Gateway Trace Delivery', - status: 'pass', - detail: `Source: ${source.name} → Delivery: ${delivery.id}`, - }); - } else if (hasSource) { - results.push({ - label: 'Gateway Trace Delivery', - status: 'fail', - detail: `Source exists (${source.name}) but no delivery/destination — traces not flowing`, - }); - } else { - results.push({ - label: 'Gateway Trace Delivery', - status: 'fail', - detail: 'Not enabled — gateway spans will not flow to aws/spans', - }); + 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: 'Gateway Trace Delivery', status: 'fail', detail: getErrorMessage(err) }); - } - - // 4. Gateway Spans in aws/spans - const fiveMinAgo = Date.now() - 5 * 60 * 1000; - try { - const spanEvents = await logsClient.send( - new FilterLogEventsCommand({ - logGroupName: 'aws/spans', - startTime: fiveMinAgo, - filterPattern: `"${gatewayId}"`, - limit: 50, - }) - ); - const count = spanEvents.events?.length ?? 0; - results.push({ - label: 'Gateway Spans (last 5m)', - status: count > 0 ? 'pass' : 'warn', - detail: count > 0 ? `${count} spans found` : 'No recent spans — send traffic through the gateway', - }); - } catch (err) { - results.push({ label: 'Gateway Spans', status: 'fail', detail: getErrorMessage(err) }); + results.push({ label: 'Runtime Experiment Spans', status: 'fail', detail: getErrorMessage(err) }); } - // 5. Eval Results — check each eval config's log group + // 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); @@ -613,25 +621,3 @@ export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScr ); } - -async function paginateDeliverySources(client: CloudWatchLogsClient): Promise { - const all: DeliverySource[] = []; - let nextToken: string | undefined; - do { - const resp = await client.send(new DescribeDeliverySourcesCommand({ nextToken })); - all.push(...(resp.deliverySources ?? [])); - nextToken = resp.nextToken; - } while (nextToken); - return all; -} - -async function paginateDeliveries(client: CloudWatchLogsClient): Promise { - const all: Delivery[] = []; - let nextToken: string | undefined; - do { - const resp = await client.send(new DescribeDeliveriesCommand({ nextToken })); - all.push(...(resp.deliveries ?? [])); - nextToken = resp.nextToken; - } while (nextToken); - return all; -} From d45abaa6a007ae8b00d10957477908b627ccc13e Mon Sep 17 00:00:00 2001 From: padmak30 Date: Thu, 30 Apr 2026 12:09:56 -0400 Subject: [PATCH 62/64] Remove stop recommendations (#162) --- src/cli/commands/pause/command.tsx | 35 ----------- .../recommendation/RecommendationFlow.tsx | 63 ++++++------------- 2 files changed, 19 insertions(+), 79 deletions(-) diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index b5b888464..82a79bccf 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -1,7 +1,6 @@ import { ConfigIO } from '../../../lib'; import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import { deleteRecommendation } from '../../aws/agentcore-recommendation'; import { getErrorMessage } from '../../errors'; import { handlePauseResume } from '../../operations/eval'; import type { OnlineEvalActionOptions } from '../../operations/eval'; @@ -252,40 +251,6 @@ export const registerStop = (program: Command) => { 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); - } - }); - - stopCmd - .command('recommendation') - .description('[preview] Stop a running recommendation (deletes the recommendation resource)') - .requiredOption('-i, --id ', 'Recommendation 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 deleteRecommendation({ - region, - recommendationId: cliOptions.id, - }); - - if (cliOptions.json) { - console.log(JSON.stringify({ success: true, ...result })); - } else { - console.log(`\nRecommendation stopped successfully`); - console.log(`ID: ${result.recommendationId}`); - console.log(`Status: ${result.status}\n`); - } - process.exit(0); } catch (error) { if (cliOptions.json) { diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index 4464b1af2..b28344df3 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -2,7 +2,6 @@ import { ConfigIO } from '../../../../lib'; import type { DeployedState } from '../../../../schema'; import { validateAwsCredentials } from '../../../aws/account'; import { listEvaluators } from '../../../aws/agentcore-control'; -import { deleteRecommendation } from '../../../aws/agentcore-recommendation'; import { detectRegion } from '../../../aws/region'; import { getErrorMessage } from '../../../errors'; import { applyRecommendationToBundle, runRecommendationCommand } from '../../../operations/recommendation'; @@ -20,8 +19,8 @@ import type { EvaluatorItem, RecommendationWizardConfig, } from './types'; -import { Box, Text, useInput } from 'ink'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useState } from 'react'; type FlowState = | { name: 'loading' } @@ -44,25 +43,6 @@ interface RecommendationFlowProps { export function RecommendationFlow({ onExit }: RecommendationFlowProps) { const [flow, setFlow] = useState({ name: 'loading' }); - const stoppingRef = useRef(false); - - // Handle Esc to stop a running recommendation - useInput((_input, key) => { - if (flow.name !== 'running' || !flow.recommendationId || !flow.region || stoppingRef.current) return; - if (key.escape) { - stoppingRef.current = true; - void deleteRecommendation({ region: flow.region, recommendationId: flow.recommendationId }).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(() => { @@ -115,28 +95,24 @@ 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' }; - } + const handleRunComplete = useCallback((config: RecommendationWizardConfig) => { + const willFetchSpans = config.traceSource === 'sessions'; - stoppingRef.current = false; - setFlow({ name: 'running', config, steps: initialSteps, elapsed: 0 }); - }, - [flow] - ); + 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(() => { @@ -324,7 +300,6 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { ({timeStr}) - {flow.recommendationId && Press Esc to stop the recommendation} From 9f703cfe6c0cdbbe264ca85755067ecf3569cea1 Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Thu, 30 Apr 2026 13:48:55 -0400 Subject: [PATCH 63/64] fix: update unit tests and remove legacy AB test API fallback (#164) * fix: update unit tests to match feat/evo-implementation changes - post-deploy-ab-tests: expect 'updated' instead of 'skipped' for existing tests - post-deploy-http-gateways: use deleteOrphanedHttpGateways, remove trace delivery tests - preflight: mock getPathResolver and fs for config bundle patching - ABTestPrimitive: expect gateway retained on remove (requires --delete-gateway) - useAddABTestWizard: first step is now 'mode' not 'name' * refactor: remove legacy /abtests API path fallback The AB test API has been migrated to /ab-tests. Remove the dpRequestWithFallback function, unused cpRequest/getControlPlaneEndpoint, and use dnsSuffix() for multi-partition support. * Revert "feat: bundle Python SDK wheel into CLI for offline install" This reverts commit 791dcfaa85773a8a94a2712f885cefe2af6ba7cb. --- scripts/bundle.mjs | 14 - src/assets/wheels/.gitkeep | 0 .../bedrock_agentcore-1.6.4-py3-none-any.whl | Bin 224156 -> 0 bytes .../aws/__tests__/agentcore-ab-tests.test.ts | 6 +- src/cli/aws/agentcore-ab-tests.ts | 59 +--- .../__tests__/post-deploy-ab-tests.test.ts | 18 +- .../post-deploy-http-gateways.test.ts | 302 +----------------- .../deploy/__tests__/preflight.test.ts | 10 + .../__tests__/ABTestPrimitive.test.ts | 3 +- .../__tests__/useAddABTestWizard.test.tsx | 12 +- src/lib/packaging/python.ts | 94 +++--- 11 files changed, 95 insertions(+), 423 deletions(-) delete mode 100644 src/assets/wheels/.gitkeep delete mode 100644 src/assets/wheels/bedrock_agentcore-1.6.4-py3-none-any.whl diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index b14c7ea0a..29cb5d745 100644 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -144,20 +144,6 @@ const bundledTarballDest = path.join(cliRoot, 'dist', 'assets', 'bundled-agentco fs.copyFileSync(cdkTarballSrc, bundledTarballDest); log(`Placed CDK tarball at ${bundledTarballDest}`); -// Step 4b: Copy Python SDK wheel into dist/assets/wheels/ if present in src/assets/wheels/ -const srcWheelsDir = path.join(cliRoot, 'src', 'assets', 'wheels'); -const distWheelsDir = path.join(cliRoot, 'dist', 'assets', 'wheels'); -if (fs.existsSync(srcWheelsDir)) { - const wheels = fs.readdirSync(srcWheelsDir).filter(f => f.endsWith('.whl')); - if (wheels.length > 0) { - fs.mkdirSync(distWheelsDir, { recursive: true }); - for (const whl of wheels) { - fs.copyFileSync(path.join(srcWheelsDir, whl), path.join(distWheelsDir, whl)); - log(`Placed Python wheel at dist/assets/wheels/${whl}`); - } - } -} - // Step 5: Bump CLI version and pack into final tarball (includes the bundled CDK tarball) const cliVersionInfo = bumpVersion(cliRoot); try { diff --git a/src/assets/wheels/.gitkeep b/src/assets/wheels/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/assets/wheels/bedrock_agentcore-1.6.4-py3-none-any.whl b/src/assets/wheels/bedrock_agentcore-1.6.4-py3-none-any.whl deleted file mode 100644 index 8c0fe12358cbeb7da0b45dce3001cbef8deff0bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224156 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 z 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 diff --git a/src/cli/aws/__tests__/agentcore-ab-tests.test.ts b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts index 1c524926a..94dca3bdb 100644 --- a/src/cli/aws/__tests__/agentcore-ab-tests.test.ts +++ b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts @@ -274,7 +274,7 @@ describe('agentcore-ab-tests', () => { ); }); - it('falls back to legacy path on 404 then returns error if both fail', async () => { + it('returns error on 404', async () => { mockFetch.mockResolvedValue({ ok: false, status: 404, @@ -284,10 +284,8 @@ describe('agentcore-ab-tests', () => { const result = await deleteABTest({ region: 'us-east-1', abTestId: 'abt-999' }); - // First call: /ab-tests/abt-999 (new path), second call: /abtests/abt-999 (legacy fallback) - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch.mock.calls[0]![0]).toContain('/ab-tests/abt-999'); - expect(mockFetch.mock.calls[1]![0]).toContain('/abtests/abt-999'); expect(result.success).toBe(false); expect(result.error).toContain('ABTest API error (404)'); }); diff --git a/src/cli/aws/agentcore-ab-tests.ts b/src/cli/aws/agentcore-ab-tests.ts index e78fb23c0..4bcf0ce16 100644 --- a/src/cli/aws/agentcore-ab-tests.ts +++ b/src/cli/aws/agentcore-ab-tests.ts @@ -5,6 +5,7 @@ * 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'; @@ -197,18 +198,11 @@ export interface ListABTestsResult { // HTTP signing helpers // ============================================================================ -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}.amazonaws.com`; -} - 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}.amazonaws.com`; + return `https://bedrock-agentcore.${region}.${dnsSuffix(region)}`; } async function signedRequestToEndpoint( @@ -267,35 +261,6 @@ async function signedRequestToEndpoint( return response.json(); } -/** - * Makes a data plane request with path fallback for the AB test API migration. - * Tries the new `/ab-tests` path first; if a 404 is returned, retries with - * the legacy `/abtests` path. - */ -async function dpRequestWithFallback(options: { - region: string; - method: string; - path: string; - body?: string; -}): Promise { - try { - return await dpRequest(options); - } catch (err) { - // If the new path returns 404, fall back to the old `/abtests` path - if (err instanceof Error && err.message.includes('(404)')) { - const legacyPath = options.path.replace('/ab-tests', '/abtests'); // old path: /abtests - return dpRequest({ ...options, path: legacyPath }); - } - throw err; - } -} - -/** Control plane request — kept for future use. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function cpRequest(options: { region: string; method: string; path: string; body?: string }): Promise { - return signedRequestToEndpoint(getControlPlaneEndpoint(options.region), options); -} - /** 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); @@ -320,10 +285,10 @@ export async function createABTest(options: CreateABTestOptions): Promise { // Data plane includes results/metrics in the response - const data = await dpRequestWithFallback({ + const data = await dpRequest({ region: options.region, method: 'GET', - path: `/ab-tests/${options.abTestId}`, // new path; falls back to /abtests/{id} (legacy) on 404 + path: `/ab-tests/${options.abTestId}`, }); return data as GetABTestResult; @@ -352,10 +317,10 @@ export async function updateABTest(options: UpdateABTestOptions): Promise { try { - await dpRequestWithFallback({ + await dpRequest({ region: options.region, method: 'DELETE', - path: `/ab-tests/${options.abTestId}`, // new path; falls back to /abtests/{id} (legacy) on 404 + path: `/ab-tests/${options.abTestId}`, }); return { success: true }; } catch (err) { @@ -381,10 +346,10 @@ export async function listABTests(options: ListABTestsOptions): Promise { ); }); - it('skips already-deployed test', async () => { + 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]), @@ -129,23 +131,26 @@ describe('setupABTests', () => { }, }); - expect(result.results[0]!.status).toBe('skipped'); + expect(result.results[0]!.status).toBe('updated'); expect(mockCreateABTest).not.toHaveBeenCalled(); + expect(mockUpdateABTest).toHaveBeenCalled(); }); - it('skips test found via API list (state loss recovery)', async () => { + 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('skipped'); + 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 () => { @@ -567,11 +572,12 @@ describe('setupABTests', () => { }); describe('mixed operations', () => { - it('creates new and skips existing', async () => { + 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', @@ -584,7 +590,7 @@ describe('setupABTests', () => { expect(result.results).toHaveLength(2); const statuses = result.results.map(r => `${r.testName}:${r.status}`); expect(statuses).toContain('NewTest:created'); - expect(statuses).toContain('KeptTest:skipped'); + expect(statuses).toContain('KeptTest:updated'); }); }); }); 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 index 607a895d5..f03143d2e 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts @@ -1,5 +1,5 @@ import type { AgentCoreProjectSpec, DeployedResourceState, HttpGatewayDeployedState } from '../../../../schema'; -import { setupHttpGateways } from '../post-deploy-http-gateways.js'; +import { deleteOrphanedHttpGateways, setupHttpGateways } from '../post-deploy-http-gateways.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────────── @@ -15,7 +15,6 @@ const { mockWaitForTargetReady, mockGetCredentialProvider, mockIAMSend, - mockCWLogsSend, } = vi.hoisted(() => ({ mockCreateHttpGateway: vi.fn(), mockCreateHttpGatewayTarget: vi.fn(), @@ -27,7 +26,6 @@ const { mockWaitForTargetReady: vi.fn(), mockGetCredentialProvider: vi.fn().mockReturnValue(undefined), mockIAMSend: vi.fn(), - mockCWLogsSend: vi.fn(), })); vi.mock('../../../aws/agentcore-http-gateways', () => ({ @@ -41,24 +39,6 @@ vi.mock('../../../aws/agentcore-http-gateways', () => ({ waitForTargetReady: mockWaitForTargetReady, })); -vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({ - CloudWatchLogsClient: class { - send = mockCWLogsSend; - }, - DescribeDeliverySourcesCommand: class { - constructor(public input: unknown) {} - }, - PutDeliverySourceCommand: class { - constructor(public input: unknown) {} - }, - PutDeliveryDestinationCommand: class { - constructor(public input: unknown) {} - }, - CreateDeliveryCommand: class { - constructor(public input: unknown) {} - }, -})); - vi.mock('../../../aws/account', () => ({ getCredentialProvider: mockGetCredentialProvider, })); @@ -128,13 +108,6 @@ describe('setupHttpGateways', () => { mockListHttpGatewayTargets.mockResolvedValue({ targets: [] }); mockWaitForGatewayReady.mockResolvedValue({ gatewayId: 'gw-001', status: 'READY' }); mockWaitForTargetReady.mockResolvedValue({}); - // Default: no existing delivery sources, and PutDeliveryDestination returns an ARN - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ deliverySources: [] }); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:aws:logs:us-east-1:123:delivery-dest/test' } }); - }); }); describe('creation', () => { @@ -301,9 +274,8 @@ describe('setupHttpGateways', () => { mockDeleteHttpGateway.mockResolvedValue({ success: true }); mockDeleteHttpGatewayTarget.mockResolvedValue({ success: true }); - const result = await setupHttpGateways({ + const result = await deleteOrphanedHttpGateways({ region: 'us-east-1', - projectName: 'TestProject', projectSpec: makeProjectSpec([]), existingHttpGateways: { RemovedGw: { @@ -330,9 +302,8 @@ describe('setupHttpGateways', () => { mockDeleteHttpGateway.mockResolvedValue({ success: true }); mockIAMSend.mockResolvedValue({}); - await setupHttpGateways({ + await deleteOrphanedHttpGateways({ region: 'us-east-1', - projectName: 'TestProject', projectSpec: makeProjectSpec([]), existingHttpGateways: { RemovedGw: { @@ -361,9 +332,8 @@ describe('setupHttpGateways', () => { it('reports error when deletion fails', async () => { mockDeleteHttpGateway.mockRejectedValue(new Error('delete failed')); - const result = await setupHttpGateways({ + const result = await deleteOrphanedHttpGateways({ region: 'us-east-1', - projectName: 'TestProject', projectSpec: makeProjectSpec([]), existingHttpGateways: { FailGw: { gatewayId: 'gw-fail', gatewayArn: 'arn:httpgw:fail' }, @@ -417,7 +387,7 @@ describe('setupHttpGateways', () => { }); describe('mixed operations', () => { - it('creates new, skips existing, and deletes orphaned in one call', async () => { + it('creates new and skips existing (orphan deletion is a separate pass)', async () => { const newGw = { ...sampleHttpGateway, name: 'NewGw' }; const keptGw = { ...sampleHttpGateway, name: 'KeptGw' }; @@ -439,269 +409,27 @@ describe('setupHttpGateways', () => { deployedResources: sampleDeployedResources, }); - expect(result.results).toHaveLength(3); + 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'); - expect(statuses).toContain('OrphanGw:deleted'); - }); - }); - - describe('trace delivery rollback (Problem 1)', () => { - it('deletes target before gateway on trace delivery failure', async () => { - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-trace-fail', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-trace-fail', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-trace-fail' }); - - // Make trace delivery fail - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ deliverySources: [] }); - } - if (cmd.constructor.name === 'PutDeliverySourceCommand') { - return Promise.reject(new Error('AccessDenied')); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); - }); - - const callOrder: string[] = []; - mockDeleteHttpGatewayTarget.mockImplementation(() => { - callOrder.push('deleteTarget'); - return Promise.resolve({ success: true }); - }); - mockDeleteHttpGateway.mockImplementation(() => { - callOrder.push('deleteGateway'); - return Promise.resolve({ 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]!.error).toContain('Trace delivery failed'); - expect(result.results[0]!.error).toContain('AccessDenied'); - - // deleteHttpGatewayTarget now waits internally, so just verify ordering - expect(callOrder).toEqual(['deleteTarget', 'deleteGateway']); }); - it('rollback cleans up auto-created role on trace delivery failure', async () => { - const gwWithoutRole = { ...sampleHttpGateway, roleArn: undefined }; - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-role-cleanup', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-role-cleanup', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-role-cleanup' }); - mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); - mockDeleteHttpGatewayTarget.mockResolvedValue({ success: true }); + it('deleteOrphanedHttpGateways removes orphans separately', async () => { mockDeleteHttpGateway.mockResolvedValue({ success: true }); - // Make trace delivery fail - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ deliverySources: [] }); - } - if (cmd.constructor.name === 'PutDeliverySourceCommand') { - return Promise.reject(new Error('AccessDenied')); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); - }); - - const result = await setupHttpGateways({ + const result = await deleteOrphanedHttpGateways({ region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([gwWithoutRole]), - deployedResources: sampleDeployedResources, - }); - - expect(result.hasErrors).toBe(true); - // IAM calls: CreateRole, PutRolePolicy (setup), then DeleteRolePolicy, DeleteRole (cleanup) - expect(mockIAMSend).toHaveBeenCalledTimes(4); - const iamCallNames = mockIAMSend.mock.calls.map( - (c: unknown[][]) => (c[0] as { constructor: { name: string } }).constructor.name - ); - expect(iamCallNames).toContain('DeleteRolePolicyCommand'); - expect(iamCallNames).toContain('DeleteRoleCommand'); - }); - }); - - describe('ensureTraceDelivery on redeploy (Problem 2)', () => { - it('enables trace delivery on existing gateway when not configured', async () => { - const existingHttpGateways: Record = { - MyHttpGw: { - gatewayId: 'gw-existing', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing', - targetId: 'tgt-existing', - }, - }; - - const cwCalls: string[] = []; - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - cwCalls.push(cmd.constructor.name); - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ deliverySources: [] }); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); - }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - existingHttpGateways, - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('skipped'); - // Should have run the full delivery chain: Describe → Put Source → Put Dest → Create Delivery - expect(cwCalls).toEqual([ - 'DescribeDeliverySourcesCommand', - 'PutDeliverySourceCommand', - 'PutDeliveryDestinationCommand', - 'CreateDeliveryCommand', - ]); - }); - - it('skips trace delivery on existing gateway when already configured', async () => { - const existingHttpGateways: Record = { - MyHttpGw: { - gatewayId: 'gw-existing', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing', - targetId: 'tgt-existing', - }, - }; - - const cwCalls: string[] = []; - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - cwCalls.push(cmd.constructor.name); - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ - deliverySources: [ - { - name: 'agentcore-gw-traces-MyHttpGw', - resourceArns: ['arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing'], - logType: 'TRACES', - }, - ], - }); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); - }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - existingHttpGateways, - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('skipped'); - // Only DescribeDeliverySources should have been called — no Put/Create - expect(cwCalls).toEqual(['DescribeDeliverySourcesCommand']); - }); - - it('does not false-positive match a gateway with a similar ID prefix', async () => { - const existingHttpGateways: Record = { - MyHttpGw: { - gatewayId: 'gw-1', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1', - targetId: 'tgt-1', - }, - }; - - const cwCalls: string[] = []; - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - cwCalls.push(cmd.constructor.name); - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ - deliverySources: [ - { - name: 'agentcore-gw-traces-OtherGw', - resourceArns: ['arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-10'], - logType: 'TRACES', - }, - ], - }); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); - }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - existingHttpGateways, - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('skipped'); - // gw-10 should NOT match gw-1 — must enable trace delivery - expect(cwCalls).toContain('PutDeliverySourceCommand'); - }); - - it('enables trace delivery on gateway found by name (state loss recovery)', async () => { - mockListAllHttpGateways.mockResolvedValue([ - { - name: 'MyHttpGw', - gatewayId: 'gw-recovered', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-recovered', - }, - ]); - - const cwCalls: string[] = []; - mockCWLogsSend.mockImplementation((cmd: { constructor: { name: string } }) => { - cwCalls.push(cmd.constructor.name); - if (cmd.constructor.name === 'DescribeDeliverySourcesCommand') { - return Promise.resolve({ deliverySources: [] }); - } - return Promise.resolve({ deliveryDestination: { arn: 'arn:logs:dest' } }); - }); - - 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-recovered'); - // Full delivery chain should have been called - expect(cwCalls).toContain('PutDeliverySourceCommand'); - expect(cwCalls).toContain('PutDeliveryDestinationCommand'); - expect(cwCalls).toContain('CreateDeliveryCommand'); - }); - - it('ensureTraceDelivery failure is non-fatal for existing gateways', async () => { - const existingHttpGateways: Record = { - MyHttpGw: { - gatewayId: 'gw-existing', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-existing', - targetId: 'tgt-existing', + projectSpec: makeProjectSpec([{ ...sampleHttpGateway, name: 'KeptGw' }]), + existingHttpGateways: { + KeptGw: { gatewayId: 'gw-kept', gatewayArn: 'arn:httpgw:kept' }, + OrphanGw: { gatewayId: 'gw-orphan', gatewayArn: 'arn:httpgw:orphan' }, }, - }; - - mockCWLogsSend.mockRejectedValue(new Error('CloudWatch Logs unavailable')); - - 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.hasErrors).toBe(false); - // Gateway data should be preserved intact - expect(result.httpGateways.MyHttpGw).toEqual(existingHttpGateways.MyHttpGw); + 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__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 12e172d17..8c4d6d57e 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; diff --git a/src/cli/primitives/__tests__/ABTestPrimitive.test.ts b/src/cli/primitives/__tests__/ABTestPrimitive.test.ts index 766063ff8..20ae26fa1 100644 --- a/src/cli/primitives/__tests__/ABTestPrimitive.test.ts +++ b/src/cli/primitives/__tests__/ABTestPrimitive.test.ts @@ -202,7 +202,8 @@ describe('ABTestPrimitive', () => { expect(result.success).toBe(true); const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; expect(writtenSpec.abTests).toHaveLength(0); - expect(writtenSpec.httpGateways).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 () => { diff --git a/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx index 234c45114..082d7662b 100644 --- a/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx +++ b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx @@ -73,9 +73,9 @@ ImperativeHarness.displayName = 'ImperativeHarness'; describe('useAddABTestWizard', () => { describe('defaults', () => { - it('default step is name', () => { + it('default step is mode', () => { const { lastFrame } = render(); - expect(lastFrame()).toContain('step:name'); + expect(lastFrame()).toContain('step:mode'); }); it('default treatment weight is 20', () => { @@ -88,11 +88,11 @@ describe('useAddABTestWizard', () => { expect(lastFrame()).toContain('enableOnCreate:true'); }); - it('has all 9 steps', () => { + it('has all 10 steps', () => { const { lastFrame } = render(); const frame = lastFrame()!.replace(/\n/g, ''); expect(frame).toContain( - 'steps:name,description,gateway,agent,variants,onlineEval,maxDuration,enableOnCreate,confirm' + 'steps:mode,name,description,gateway,agent,variants,onlineEval,maxDuration,enableOnCreate,confirm' ); }); }); @@ -211,7 +211,7 @@ describe('useAddABTestWizard', () => { const { lastFrame } = render(); act(() => ref.current!.goBack()); - expect(lastFrame()).toContain('step:name'); + expect(lastFrame()).toContain('step:mode'); }); }); @@ -226,7 +226,7 @@ describe('useAddABTestWizard', () => { act(() => ref.current!.reset()); - expect(lastFrame()).toContain('step:name'); + expect(lastFrame()).toContain('step:mode'); expect(lastFrame()).toContain('name:'); expect(lastFrame()).toContain('treatmentWeight:20'); }); diff --git a/src/lib/packaging/python.ts b/src/lib/packaging/python.ts index 784356e50..eac12567f 100644 --- a/src/lib/packaging/python.ts +++ b/src/lib/packaging/python.ts @@ -22,28 +22,8 @@ import { } from './helpers'; import type { ArtifactResult, CodeZipPackager, PackageOptions, RuntimePackager } from './types/packaging'; import { detectUnavailablePlatform } from './uv'; -import { existsSync, readdirSync } from 'fs'; import { join } from 'path'; -/** - * Path to bundled Python wheels shipped inside the npm package. - * When present, `--find-links` is added to `uv pip install` so uv - * prefers the local wheel over fetching from PyPI. - */ -// In the esbuild-bundled CLI (dist/cli/index.mjs), __dirname is dist/cli/, -// so ../assets/wheels resolves to dist/assets/wheels/ — which is correct. -// The tsc CJS build (dist/lib/packaging/python.js) is not used by end users. -const BUNDLED_WHEELS_DIR = join(__dirname, '..', 'assets', 'wheels'); - -/** Returns true if the bundled wheels directory exists and contains at least one .whl file. */ -function hasBundledWheels(): boolean { - try { - return existsSync(BUNDLED_WHEELS_DIR) && readdirSync(BUNDLED_WHEELS_DIR).some(f => f.endsWith('.whl')); - } catch { - return false; - } -} - // eslint-disable-next-line security/detect-unsafe-regex -- bounded input from RuntimeVersion enum, not user input const PYTHON_RUNTIME_REGEX = /PYTHON_(\d+)_?(\d+)?/; @@ -131,25 +111,24 @@ export class PythonCodeZipPackager implements RuntimePackager { } await ensureDirClean(stagingDir); - const uvArgs = [ - 'pip', - 'install', - '-r', - pyprojectPath, - '--target', - stagingDir, - '--python-version', - pythonVersion, - '--python-platform', - platform, - '--only-binary', - ':all:', - ]; - if (hasBundledWheels()) { - uvArgs.push('--find-links', BUNDLED_WHEELS_DIR); - } - - const result = await runSubprocessCapture('uv', uvArgs, { cwd: projectRoot }); + const result = await runSubprocessCapture( + 'uv', + [ + 'pip', + 'install', + '-r', + pyprojectPath, + '--target', + stagingDir, + '--python-version', + pythonVersion, + '--python-platform', + platform, + '--only-binary', + ':all:', + ], + { cwd: projectRoot } + ); if (result.code === 0) { await copySourceTree(srcDir, stagingDir); @@ -229,25 +208,24 @@ export class PythonCodeZipPackagerSync implements CodeZipPackager { } ensureDirCleanSync(stagingDir); - const uvArgs = [ - 'pip', - 'install', - '-r', - pyprojectPath, - '--target', - stagingDir, - '--python-version', - pythonVersion, - '--python-platform', - platform, - '--only-binary', - ':all:', - ]; - if (hasBundledWheels()) { - uvArgs.push('--find-links', BUNDLED_WHEELS_DIR); - } - - const result = runSubprocessCaptureSync('uv', uvArgs, { cwd: projectRoot }); + const result = runSubprocessCaptureSync( + 'uv', + [ + 'pip', + 'install', + '-r', + pyprojectPath, + '--target', + stagingDir, + '--python-version', + pythonVersion, + '--python-platform', + platform, + '--only-binary', + ':all:', + ], + { cwd: projectRoot } + ); if (result.code === 0) { copySourceTreeSync(srcDir, stagingDir); From 5d0ec2442df4e205025abf3d661db3e3eefb6013 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 30 Apr 2026 13:53:20 -0400 Subject: [PATCH 64/64] =?UTF-8?q?fix:=20resolve=20merge=20conflicts=20with?= =?UTF-8?q?=20public/main=20=E2=80=94=20take=20public=20versions=20for=20t?= =?UTF-8?q?elemetry,=20web-ui,=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/harness-bug-bash.md | 362 ++++++++++++++++++ src/cli/operations/dev/web-ui/api-types.ts | 34 ++ .../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/telemetry/config.ts | 5 + src/cli/telemetry/index.ts | 4 +- src/cli/telemetry/schemas/command-run.ts | 1 + 9 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 docs/harness-bug-bash.md diff --git a/docs/harness-bug-bash.md b/docs/harness-bug-bash.md new file mode 100644 index 000000000..ee46111b7 --- /dev/null +++ b/docs/harness-bug-bash.md @@ -0,0 +1,362 @@ +# Harness Feature Bug Bash — `feat/harness-implementation` + +**Branch:** `feat/harness-implementation` +**CLI Version:** 0.9.1 +**Date:** 2026-04-22 +**Environment:** `AGENTCORE_STAGE=beta`, `AWS_REGION=us-west-2`, Account: `409645756810` + +--- + +## Summary + +Comprehensive first-time-user CLI testing of all harness commands. Tested: `create`, `add harness`, `add tool`, `remove tool`, `remove harness`, `validate`, `deploy`, `status`, `invoke`, `logs`, `traces`, `fetch access`, and `dev`. Covered Bedrock, OpenAI, and Gemini providers. + +**Total findings: 22** (9 bugs, 5 UX quirks, 4 missing features, 4 documentation gaps) + +--- + +## Bugs + +### BUG-001: `agentcore status` shows harness entry twice (P1) + +**Severity:** High — affects every status check +**Repro:** +```bash +agentcore status +agentcore status --json +agentcore status --type harness --json +``` +**Expected:** Harness appears once in output +**Actual:** Harness appears twice, both in text and JSON output. React warning: `Encountered two children with the same key, 'harness-deploytestbr'` +**JSON output shows duplicate:** +```json +"resources": [ + { "resourceType": "harness", "name": "deploytestbr", ... }, + { "resourceType": "harness", "name": "deploytestbr", ... } +] +``` + +--- + +### BUG-002: `aws-targets.json` not populated on harness `create` (P1) + +**Severity:** High — deploy fails immediately after create +**Repro:** +```bash +agentcore create --name myproject --model-provider bedrock --skip-install --skip-git +cd myproject +agentcore deploy -y +# Error: Target "default" not found in aws-targets.json +``` +**Expected:** `aws-targets.json` should be auto-populated with a default target (inferring account/region from current AWS credentials), or the create summary should tell the user to set it up. +**Actual:** `aws-targets.json` is empty `[]`. User must manually create the target entry, and the field name (`account` not `accountId`) is not documented anywhere in the create output. + +--- + +### BUG-003: `create --model-provider` help text lists invalid provider names (P2) + +**Severity:** Medium — blocks first-time users +**Repro:** +```bash +agentcore create --name test --model-provider openai --model-id gpt-4o --api-key-arn arn:... +# Error: Invalid model provider: openai. Use bedrock, open_ai, or gemini + +agentcore create --name test --model-provider anthropic --model-id claude-3-5-sonnet +# Error: Invalid model provider: anthropic. Use bedrock, open_ai, or gemini +``` +**Expected:** `create --help` and `add harness --help` should show consistent, correct provider names +**Actual:** +- `create --help` says: `--model-provider Model provider (Bedrock, Anthropic, OpenAI, Gemini)` +- `add harness --help` says: `--model-provider Model provider: bedrock, open_ai, gemini` +- `Anthropic` is listed in create help but not a valid harness provider at all +- `OpenAI` is listed in create help but the actual accepted value is `open_ai` (with underscore) + +**Fix:** Update `create --help` to: `Model provider: bedrock, open_ai, gemini (for harness path)` or accept both `openai` and `open_ai` via normalization. + +--- + +### BUG-004: `create` with Dockerfile path resolves relative to project dir, not CWD (P2) + +**Severity:** Medium +**Repro:** +```bash +# From /tmp/harness-bug-bash: +agentcore create --name containertest --model-provider bedrock --model-id x --container ./Dockerfile +# Error: Dockerfile not found at: /tmp/harness-bug-bash/containertest/Dockerfile +``` +**Expected:** `./Dockerfile` resolves relative to CWD (`/tmp/harness-bug-bash/Dockerfile`) +**Actual:** Resolves relative to the newly created project directory. Confusing because the project didn't exist before the command ran. + +--- + +### BUG-005: Failed `create` leaves partial project directory (P2) + +**Severity:** Medium — leaves confusing broken state +**Repro:** +```bash +agentcore create --name containertest --model-provider bedrock --container ./Dockerfile --skip-install --skip-git +# Fails because Dockerfile not found +ls containertest/ +# Shows: agentcore/ AGENTS.md README.md (but no app/ harness directory) +``` +**Expected:** On failure, the partially created project directory should be cleaned up +**Actual:** Directory left behind with `agentcore/`, `AGENTS.md`, `README.md` but no harness app. This is a broken, non-functional project state. + +--- + +### BUG-006: `create --session-storage-mount-path` not passed through to harness config (P2) + +**Severity:** Medium — silently drops configuration +**Repro:** +```bash +agentcore create --name sessionmount --model-provider bedrock --session-storage-mount-path /mnt/data --skip-git --skip-install +cat sessionmount/app/sessionmount/harness.json +# No sessionStoragePath field present +``` +**Expected:** `harness.json` should contain `"sessionStoragePath": "/mnt/data"` +**Actual:** The flag is accepted silently but the value is never written to the harness config. +**Workaround:** Use `agentcore add harness --session-storage /mnt/data` which works correctly. + +--- + +### BUG-007: `remove harness` leaves orphaned memory (P2) + +**Severity:** Medium — configuration drift +**Repro:** +```bash +agentcore add harness --name myharness --model-provider bedrock --json +# Creates myharness + myharnessMemory +agentcore remove harness --name myharness -y --json +# Removes harness but myharnessMemory stays in agentcore.json memories[] +``` +**Expected:** Removing a harness should also remove (or offer to remove) its auto-created memory +**Actual:** Memory is orphaned in the config. On next deploy, the orphaned memory still gets deployed. + +--- + +### BUG-008: CDK trust policy missing beta/preprod service principal (P2) + +**Severity:** Medium — deploy always fails on first try for beta stage +**Repro:** +```bash +export AGENTCORE_STAGE=beta +agentcore deploy -y +# Step "Deploy harnesses" fails: Role validation failed +``` +**Expected:** When `AGENTCORE_STAGE=beta`, the CDK-generated IAM role trust policy should include `preprod.genesis-service.aws.internal` as a principal +**Actual:** Trust policy only includes `bedrock-agentcore.amazonaws.com`. Must manually run: +```bash +aws iam update-assume-role-policy --role-name --policy-document '{"Statement":[{"Principal":{"Service":["bedrock-agentcore.amazonaws.com","preprod.genesis-service.aws.internal"]},...}]}' +``` +Then re-deploy. + +--- + +### BUG-009: No validation on `--session-storage` path (P3) + +**Severity:** Low — bad config accepted, will fail at deploy/runtime +**Repro:** +```bash +agentcore add harness --name test --model-provider bedrock --session-storage "not-a-path" --json +# Success — no validation error + +agentcore add harness --name test2 --model-provider bedrock --session-storage /tmp/data --json +# Success — not under /mnt, but still accepted +``` +**Expected:** Validate that the path is an absolute path starting with `/mnt/` as documented in the help text +**Actual:** Any string is accepted. + +--- + +## UX Quirks + +### QUIRK-001: `create` with no flags defaults to harness path + +Running `agentcore create --name foo --skip-install --skip-git` (no `--model-provider`, no `--framework`) creates a harness project, not the traditional agent project. This could confuse existing users who expect the interactive TUI or an agent project by default. + +**Suggestion:** Either require explicit `--model-provider` to trigger harness path, or show a clear message: "Creating a harness project (pass --framework to create an agent project instead)." + +--- + +### QUIRK-002: Deprecated model IDs accepted at create time, fail at invoke time + +```bash +agentcore create --name x --model-provider bedrock --model-id anthropic.claude-3-5-sonnet-20240620-v1:0 +# Success! +agentcore invoke --harness x "hello" +# Error: This model version has reached the end of its life +``` + +The deprecated model is only caught at runtime. Would be better to warn (or reject) during create/add. + +--- + +### QUIRK-003: `--stream` output looks identical to non-stream + +Both `agentcore invoke --harness x "hi"` and `agentcore invoke --harness x --stream "hi"` show a spinner, then the full text. There's no visible difference from the user's perspective. Stream should show tokens incrementally. + +--- + +### QUIRK-004: `traces list` shows `unknown` trace ID for one entry + +First trace in the list had `Trace ID: unknown` and no session ID. Likely a health check or cold start trace, but it's confusing to users. + +--- + +### QUIRK-005: `--api-key-arn` should use AgentCore Identity (not Secrets Manager) + +The `--api-key-arn` flag name implies Secrets Manager ARNs, but per project requirements it should be using AgentCore Identity for credential management. This needs clarification or rework. + +--- + +## Missing Features + +### FEAT-001: `fetch access` does not support `--harness` type + +`agentcore fetch access` only supports `--type gateway` and `--type agent`. There is `fetch-harness-token.ts` in the codebase but it's not wired to the CLI command. Users need a way to get the harness endpoint URL and auth info. + +--- + +### FEAT-002: `dev` command has no `--harness` support + +`agentcore dev` only works with `--runtime` (agents). There's no local development mode for harnesses. Even `--no-browser` only starts a terminal TUI for agents. + +**Impact:** No local development/testing workflow for harness projects. + +--- + +### FEAT-003: `status` has no `--harness ` filter + +`agentcore status` has `--runtime ` to filter by runtime name, but no equivalent `--harness `. The only filter is `--type harness` which shows all harnesses. + +--- + +### FEAT-004: Long-term memory not working with harness + +Memory is configured, deployed (status READY), and strategies are set (SEMANTIC, USER_PREFERENCE, SUMMARIZATION, EPISODIC), but: +- The model reports it has no memory tools +- Cross-session recall doesn't work (new session can't remember facts from prior session) +- No memory-related events in verbose streaming output + +This may be a backend issue rather than a CLI issue, but the CLI should verify memory is actually functional or document the current limitations. + +--- + +## Documentation Gaps + +### DOC-001: No harness documentation in `docs/` + +There is no dedicated harness documentation file (e.g., `docs/harness.md`). The existing docs cover agents, gateways, memory, policies, evals, etc. but harness is only mentioned in `docs/tui-harness.md` (TUI testing harness) and internal plan docs. + +**Needed:** `docs/harness.md` covering: +- What is a harness and when to use it vs. agents +- Creating a harness project (CLI + TUI) +- Configuring model providers (bedrock, open_ai, gemini) +- Adding tools (all 4 types with examples) +- Session storage +- Custom JWT auth +- Deploying and invoking +- System prompt customization +- Invoke overrides (--model-id, --system-prompt, --max-iterations, etc.) + +--- + +### DOC-002: `create` help text doesn't explain harness vs agent paths + +The `create --help` mixes agent flags and harness flags without explaining: +- Which flags trigger which path +- That `--model-provider` triggers the harness path +- That `--framework` triggers the agent path +- That they can't be mixed + +A section in the help output or a `agentcore help create` long-form doc would help. + +--- + +### DOC-003: No docs on `aws-targets.json` for harness projects + +Agent projects presumably auto-populate this or guide the user through it. Harness projects leave it empty. There's no documentation on the expected format: +```json +[{ "name": "default", "account": "123456789012", "region": "us-west-2" }] +``` + +--- + +### DOC-004: Invoke override flags undocumented beyond help text + +The `invoke --help` shows many harness-specific override flags (`--model-id`, `--tools`, `--max-iterations`, `--max-tokens`, `--harness-timeout`, `--skills`, `--system-prompt`, `--allowed-tools`, `--actor-id`) but there's no documentation explaining: +- What each override does in practice +- Which overrides persist vs. are per-invocation +- Interaction between overrides (e.g., `--tools` vs `--allowed-tools`) + +--- + +## Test Matrix + +| Command | Test | Result | +|---------|-------|--------| +| `create --model-provider bedrock` | Basic harness create | PASS | +| `create --model-provider open_ai --api-key-arn` | OpenAI harness | PASS | +| `create --model-provider gemini --api-key-arn` | Gemini harness | PASS | +| `create --model-provider openai` | Casing normalization | FAIL (BUG-003) | +| `create --model-provider anthropic` | Anthropic provider | FAIL (BUG-003) | +| `create` (no model-provider) | Default path | PASS (quirk: defaults to harness) | +| `create --model-provider bedrock --framework Strands` | Mixed flags | PASS (rejected with clear error) | +| `create --no-harness-memory` | Skip memory | PASS | +| `create --max-iterations --max-tokens --timeout --truncation-strategy` | All optional flags | PASS | +| `create --container ` | Container URI | PASS | +| `create --container ./Dockerfile` | Dockerfile path | FAIL (BUG-004, BUG-005) | +| `create --session-storage-mount-path` | Session storage via create | FAIL (BUG-006) | +| `create --dry-run` | Not tested | — | +| `add harness --name --model-provider bedrock` | Add to existing project | PASS | +| `add harness` (duplicate name) | Duplicate rejection | PASS | +| `add harness` (invalid name) | Name validation | PASS | +| `add harness --network-mode VPC` (no subnets) | VPC validation | PASS | +| `add harness --session-storage /mnt/data` | Session storage via add | PASS | +| `add harness --session-storage not-a-path` | Invalid path | FAIL (BUG-009) | +| `add harness --authorizer-type CUSTOM_JWT` | JWT auth | PASS | +| `add harness --with-invoke-script` | Invoke script generation | PASS | +| `add tool --type remote_mcp --url` | MCP tool | PASS | +| `add tool --type agentcore_browser` | Browser tool | PASS | +| `add tool --type agentcore_code_interpreter` | Code interpreter | PASS | +| `add tool --type agentcore_gateway --gateway-arn` | Gateway tool | PASS | +| `add tool` (duplicate name) | Duplicate rejection | PASS | +| `add tool --harness nonexistent` | Missing harness | PASS | +| `add tool --type remote_mcp` (no url) | Missing URL validation | PASS | +| `add tool --type invalid_type` | Invalid type error | PASS (but mentions `inline_function` — see note) | +| `remove tool --harness x --name y` | Remove tool | PASS | +| `remove tool` (nonexistent) | Missing tool | PASS | +| `remove harness --name x -y` | Remove harness | PASS (but BUG-007) | +| `remove harness` (nonexistent) | Missing harness | PASS | +| `validate` (valid config) | Validation | PASS | +| `validate` (missing model) | Error detection | PASS | +| `validate` (invalid provider) | Provider validation | PASS | +| `validate` (orphaned memory ref) | Cross-reference check | PASS | +| `deploy -y` | Deploy harness | PASS (after BUG-002 + BUG-008 workarounds) | +| `deploy -y` (empty aws-targets) | First deploy | FAIL (BUG-002) | +| `status` | Show harness | FAIL (BUG-001, duplicate) | +| `status --type harness --json` | Filter + JSON | FAIL (BUG-001, duplicate) | +| `invoke --harness x "prompt"` | Basic invoke | PASS (with valid model) | +| `invoke --harness x --model-id y "prompt"` | Model override | PASS | +| `invoke --harness x --verbose "prompt"` | Verbose streaming | PASS | +| `invoke --harness x --json "prompt"` | JSON output | PASS | +| `invoke --harness x --stream "prompt"` | Streaming | PASS (QUIRK-003) | +| `invoke --harness x --session-id y "prompt"` | Session continuity | PASS | +| `invoke --harness x --system-prompt y "prompt"` | System prompt override | PASS | +| `invoke` (deprecated model) | Model validation | FAIL (QUIRK-002) | +| Cross-session memory recall | Long-term memory | FAIL (FEAT-004) | +| `logs --harness x --limit 5` | Harness logs | PASS | +| `logs --harness x --json` | JSON logs | PASS | +| `traces list --harness x` | List traces | PASS | +| `traces get --harness x ` | Download trace | PASS | +| `fetch access --type harness` | Fetch harness token | FAIL (FEAT-001) | +| `dev --harness` | Local dev for harness | N/A (FEAT-002) | + +--- + +## Notes + +- **`inline_function` tool type:** The error message for invalid tool types lists `inline_function` as valid, but `add tool --help` only shows 4 types and commit `06deb79` removed inline_function tool approval. Inconsistency between validation and help text. +- **`--json` consistency:** All harness commands consistently support `--json` output. Good. +- **Error messages:** Generally clear and actionable across all commands. Good DX. +- **Validation:** Harness config validation (via `agentcore validate`) is thorough — catches missing fields, invalid providers, orphaned memory references. 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/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/telemetry/config.ts b/src/cli/telemetry/config.ts index 5bee94eff..364d57f68 100644 --- a/src/cli/telemetry/config.ts +++ b/src/cli/telemetry/config.ts @@ -3,6 +3,7 @@ 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