Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -906,6 +907,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
previousKnowledgeBases: existingState?.targets?.[target.name]?.resources?.knowledgeBases,
targetName: target.name,
deployedState,
projectRoot: resolve(configIO.getConfigRoot(), '..'),
onProgress: msg => logger.log(msg),
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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());
Expand Down
35 changes: 27 additions & 8 deletions src/cli/operations/deploy/post-deploy-knowledge-bases.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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');
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/screens/deploy/useDeployFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -555,6 +556,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState
previousKnowledgeBases,
targetName: target.name,
deployedState,
projectRoot: resolve(configIO.getConfigRoot(), '..'),
onProgress: msg => logger.log(msg),
});

Expand Down
Loading