diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 6aeaf852a..841355934 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -59,6 +59,7 @@ import { hydrateKnowledgeBaseDataSources } from '../../operations/knowledge-base import { toStackName } from '../import/import-utils'; import type { DeployResult } from './types'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; +import { resolve } from 'node:path'; export interface ValidatedDeployOptions { target: string; @@ -906,6 +907,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise logger.log(msg), }); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts index 934ff6a58..688b0fb59 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts @@ -1,5 +1,9 @@ import * as ingest from '../../ingest'; import { autoIngestKnowledgeBases, computeSourcesHash } from '../post-deploy-knowledge-bases'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../ingest'); @@ -43,6 +47,81 @@ describe('computeSourcesHash', () => { }); }); +describe('computeSourcesHash — connector config file content', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = join(tmpdir(), `agentcore-hash-test-${randomUUID()}`); + mkdirSync(join(projectRoot, 'app/myKb'), { recursive: true }); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + }); + + const kbWithConnector = (name: string, configFile: string) => + ({ + type: 'AgentCoreKnowledgeBase', + name, + dataSources: [{ type: 'CONFLUENCE', connectorConfigFile: configFile }], + }) as never; + + it('hashes file content when projectRoot is provided', () => { + const configFile = 'app/myKb/confluence.json'; + writeFileSync(join(projectRoot, configFile), JSON.stringify({ type: 'CONFLUENCE', hostUrl: 'https://a.com' })); + const kb = kbWithConnector('myKb', configFile); + + const hash1 = computeSourcesHash(kb, projectRoot); + + writeFileSync(join(projectRoot, configFile), JSON.stringify({ type: 'CONFLUENCE', hostUrl: 'https://b.com' })); + const hash2 = computeSourcesHash(kb, projectRoot); + + expect(hash1).not.toBe(hash2); + }); + + it('produces same hash when file content is unchanged', () => { + const configFile = 'app/myKb/confluence.json'; + writeFileSync(join(projectRoot, configFile), JSON.stringify({ type: 'CONFLUENCE', hostUrl: 'https://a.com' })); + const kb = kbWithConnector('myKb', configFile); + + expect(computeSourcesHash(kb, projectRoot)).toBe(computeSourcesHash(kb, projectRoot)); + }); + + it('normalizes JSON whitespace differences', () => { + const configFile = 'app/myKb/confluence.json'; + const kb = kbWithConnector('myKb', configFile); + + writeFileSync(join(projectRoot, configFile), '{"type":"CONFLUENCE","hostUrl":"https://a.com"}'); + const hash1 = computeSourcesHash(kb, projectRoot); + + writeFileSync(join(projectRoot, configFile), '{\n "type": "CONFLUENCE",\n "hostUrl": "https://a.com"\n}'); + const hash2 = computeSourcesHash(kb, projectRoot); + + expect(hash1).toBe(hash2); + }); + + it('falls back to path-based hash when file cannot be read', () => { + const configFile = 'app/myKb/missing.json'; + const kb = kbWithConnector('myKb', configFile); + + const hashWithRoot = computeSourcesHash(kb, projectRoot); + const hashWithoutRoot = computeSourcesHash(kb); + + expect(hashWithRoot).toBe(hashWithoutRoot); + }); + + it('falls back to path-based hash when projectRoot is not provided', () => { + const configFile = 'app/myKb/confluence.json'; + writeFileSync(join(projectRoot, configFile), JSON.stringify({ type: 'CONFLUENCE', hostUrl: 'https://a.com' })); + const kb = kbWithConnector('myKb', configFile); + + const hashWithRoot = computeSourcesHash(kb, projectRoot); + const hashWithoutRoot = computeSourcesHash(kb); + + expect(hashWithRoot).not.toBe(hashWithoutRoot); + }); +}); + describe('autoIngestKnowledgeBases', () => { beforeEach(() => vi.mocked(ingest.runKbIngestionByName).mockReset()); afterEach(() => vi.restoreAllMocks()); diff --git a/src/cli/operations/deploy/post-deploy-knowledge-bases.ts b/src/cli/operations/deploy/post-deploy-knowledge-bases.ts index 1f2cce667..e6c6ae724 100644 --- a/src/cli/operations/deploy/post-deploy-knowledge-bases.ts +++ b/src/cli/operations/deploy/post-deploy-knowledge-bases.ts @@ -1,6 +1,8 @@ import type { DeployedState, KnowledgeBase, KnowledgeBaseDeployedState } from '../../../schema'; import { runKbIngestionByName } from '../ingest'; import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; export interface AutoIngestKnowledgeBasesOptions { region: string; @@ -13,6 +15,8 @@ export interface AutoIngestKnowledgeBasesOptions { targetName: string; /** Full deployed-state (passed through to runKbIngestionByName). */ deployedState: DeployedState; + /** Project root directory for resolving connector config file paths. */ + projectRoot?: string; /** * Optional progress callback. When the retry loop sleeps because Bedrock * is busy with a sibling job, this is called with a short status line so @@ -42,14 +46,29 @@ export interface AutoIngestKnowledgeBasesResult { } /** - * Compute the SHA-256 over the data-source URIs of a KB spec, joined with - * newlines. The post-deploy hook compares this to the previously-stored - * sourcesHash to decide whether to re-trigger ingestion. + * Compute a SHA-256 over each data source's identity + configuration content. + * For S3 data sources, the URI is the full configuration. For connector-file + * data sources, the file contents are hashed so that edits to the connector + * configuration (filters, host, credentials) trigger re-ingestion even when + * the file path stays the same. If the file cannot be read, falls back to + * hashing the path to avoid blocking the deploy. */ -export function computeSourcesHash(kb: KnowledgeBase): string { - return createHash('sha256') - .update(kb.dataSources.map(ds => (ds.type === 'S3' ? ds.uri : ds.connectorConfigFile)).join('\n')) - .digest('hex'); +export function computeSourcesHash(kb: KnowledgeBase, projectRoot?: string): string { + const parts = kb.dataSources.map(ds => { + if (ds.type === 'S3') return ds.uri; + if (projectRoot) { + try { + const abs = resolve(projectRoot, ds.connectorConfigFile); + const content = readFileSync(abs, 'utf-8'); + const normalized = JSON.stringify(JSON.parse(content)); + return `${ds.connectorConfigFile}:${normalized}`; + } catch { + return ds.connectorConfigFile; + } + } + return ds.connectorConfigFile; + }); + return createHash('sha256').update(parts.join('\n')).digest('hex'); } /** @@ -89,7 +108,7 @@ export async function autoIngestKnowledgeBases( continue; } - const newHash = computeSourcesHash(kb); + const newHash = computeSourcesHash(kb, opts.projectRoot); const previousHash = opts.previousKnowledgeBases?.[kb.name]?.sourcesHash; if (previousHash && previousHash === newHash) { diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 244ec996b..c866b924c 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -46,6 +46,7 @@ import { } from '../../components'; import { type MissingCredential, type PreflightContext, useCdkPreflight } from '../../hooks'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; +import { resolve } from 'node:path'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; type DeployPhase = @@ -555,6 +556,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState previousKnowledgeBases, targetName: target.name, deployedState, + projectRoot: resolve(configIO.getConfigRoot(), '..'), onProgress: msg => logger.log(msg), });