mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
Merge remote-tracking branch 'origin/main' into metabase-mapping-ktx-yaml
# Conflicts: # packages/cli/src/ingest.test-utils.ts # packages/cli/src/ingest.test.ts
This commit is contained in:
commit
bdeb935db9
162 changed files with 9255 additions and 2522 deletions
|
|
@ -1,152 +0,0 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
KTX_AGENT_MAX_ROWS_CAP,
|
||||
createKtxAgentRuntime,
|
||||
parseAgentMaxRows,
|
||||
readAgentJsonFile,
|
||||
writeAgentJson,
|
||||
writeAgentJsonError,
|
||||
} from './agent-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => (stdout += chunk) },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent runtime helpers', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-agent-runtime-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes JSON success and error envelopes without color or spinners', () => {
|
||||
const successIo = makeIo();
|
||||
const errorIo = makeIo();
|
||||
|
||||
writeAgentJson(successIo.io, { ok: true });
|
||||
writeAgentJsonError(errorIo.io, 'missing source', { code: 'NOT_FOUND' });
|
||||
|
||||
expect(JSON.parse(successIo.stdout())).toEqual({ ok: true });
|
||||
expect(successIo.stderr()).toBe('');
|
||||
expect(JSON.parse(errorIo.stderr())).toEqual({
|
||||
ok: false,
|
||||
error: { message: 'missing source', code: 'NOT_FOUND' },
|
||||
});
|
||||
expect(errorIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('reads JSON query files as objects', async () => {
|
||||
const path = join(tempDir, 'query.json');
|
||||
await writeFile(path, '{"measures":["revenue"],"limit":50}', 'utf-8');
|
||||
|
||||
await expect(readAgentJsonFile(path)).resolves.toEqual({ measures: ['revenue'], limit: 50 });
|
||||
});
|
||||
|
||||
it('rejects non-object JSON query files', async () => {
|
||||
const path = join(tempDir, 'query.json');
|
||||
await writeFile(path, '["revenue"]', 'utf-8');
|
||||
|
||||
await expect(readAgentJsonFile(path)).rejects.toThrow('must contain a JSON object');
|
||||
});
|
||||
|
||||
it('requires positive row limits and enforces the agent cap', () => {
|
||||
expect(parseAgentMaxRows(100)).toBe(100);
|
||||
expect(() => parseAgentMaxRows(undefined)).toThrow('maxRows is required');
|
||||
expect(() => parseAgentMaxRows(0)).toThrow('positive integer');
|
||||
expect(() => parseAgentMaxRows(KTX_AGENT_MAX_ROWS_CAP + 1)).toThrow(String(KTX_AGENT_MAX_ROWS_CAP));
|
||||
});
|
||||
|
||||
it('constructs local context ports with semantic compute and query executor', async () => {
|
||||
const project = {
|
||||
projectDir: tempDir,
|
||||
configPath: join(tempDir, 'ktx.yaml'),
|
||||
config: { project: 'revenue', connections: {} },
|
||||
coreConfig: {},
|
||||
git: {},
|
||||
fileStore: {},
|
||||
} as never;
|
||||
const ports = { knowledge: {}, semanticLayer: {} } as never;
|
||||
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const queryExecutor = { execute: vi.fn() };
|
||||
const loadProject = vi.fn(async () => project);
|
||||
const createContextTools = vi.fn(() => ports);
|
||||
|
||||
await expect(
|
||||
createKtxAgentRuntime(
|
||||
{ projectDir: tempDir, enableSemanticCompute: true, enableQueryExecution: true },
|
||||
{
|
||||
loadProject,
|
||||
createContextTools,
|
||||
createSemanticLayerCompute: () => semanticLayerCompute,
|
||||
createQueryExecutor: () => queryExecutor,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ project, ports, queryExecutor });
|
||||
|
||||
expect(loadProject).toHaveBeenCalledWith({ projectDir: tempDir });
|
||||
expect(createContextTools).toHaveBeenCalledWith(project, {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates managed semantic compute when no test override is injected', async () => {
|
||||
const project = {
|
||||
projectDir: tempDir,
|
||||
configPath: join(tempDir, 'ktx.yaml'),
|
||||
config: { project: 'revenue', connections: {} },
|
||||
coreConfig: {},
|
||||
git: {},
|
||||
fileStore: {},
|
||||
} as never;
|
||||
const ports = { semanticLayer: {} } as never;
|
||||
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const loadProject = vi.fn(async () => project);
|
||||
const createContextTools = vi.fn(() => ports);
|
||||
const createManagedSemanticLayerCompute = vi.fn(async () => semanticLayerCompute);
|
||||
const { io } = makeIo();
|
||||
|
||||
await expect(
|
||||
createKtxAgentRuntime(
|
||||
{
|
||||
projectDir: tempDir,
|
||||
enableSemanticCompute: true,
|
||||
enableQueryExecution: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
io,
|
||||
},
|
||||
{
|
||||
loadProject,
|
||||
createContextTools,
|
||||
createManagedSemanticLayerCompute,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ project, ports, semanticLayerCompute });
|
||||
|
||||
expect(createManagedSemanticLayerCompute).toHaveBeenCalledWith({
|
||||
cliVersion: '0.2.0',
|
||||
installPolicy: 'auto',
|
||||
io,
|
||||
});
|
||||
expect(createContextTools).toHaveBeenCalledWith(project, {
|
||||
semanticLayerCompute,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import { createLocalProjectMcpContextPorts, type KtxMcpContextPorts } from '@ktx/context/mcp';
|
||||
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
} from './managed-python-command.js';
|
||||
|
||||
export const KTX_AGENT_MAX_ROWS_CAP = 1000;
|
||||
|
||||
export interface KtxAgentRuntimeOptions {
|
||||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
io?: KtxCliIo;
|
||||
}
|
||||
|
||||
export interface KtxAgentRuntime {
|
||||
project: KtxLocalProject;
|
||||
ports: KtxMcpContextPorts;
|
||||
semanticLayerCompute?: KtxSemanticLayerComputePort;
|
||||
queryExecutor?: KtxSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
export interface KtxAgentRuntimeDeps {
|
||||
loadProject?: typeof loadKtxProject;
|
||||
createContextTools?: typeof createLocalProjectMcpContextPorts;
|
||||
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
|
||||
createManagedSemanticLayerCompute?: typeof createManagedPythonSemanticLayerComputePort;
|
||||
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
export function writeAgentJson(io: KtxCliIo, value: unknown): void {
|
||||
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function writeAgentJsonError(
|
||||
io: KtxCliIo,
|
||||
message: string,
|
||||
detail: Record<string, unknown> = {},
|
||||
): void {
|
||||
io.stderr.write(`${JSON.stringify({ ok: false, error: { message, ...detail } }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function readAgentJsonFile(path: string): Promise<Record<string, unknown>> {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${path} must contain a JSON object.`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseAgentMaxRows(value: number | undefined): number {
|
||||
if (!Number.isInteger(value) || value === undefined || value <= 0) {
|
||||
throw new Error('maxRows is required and must be a positive integer.');
|
||||
}
|
||||
if (value > KTX_AGENT_MAX_ROWS_CAP) {
|
||||
throw new Error(`maxRows must be less than or equal to ${KTX_AGENT_MAX_ROWS_CAP}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function createAgentSemanticLayerCompute(
|
||||
options: KtxAgentRuntimeOptions,
|
||||
deps: KtxAgentRuntimeDeps,
|
||||
): Promise<KtxSemanticLayerComputePort | undefined> {
|
||||
if (!options.enableSemanticCompute) {
|
||||
return undefined;
|
||||
}
|
||||
if (deps.createSemanticLayerCompute) {
|
||||
return deps.createSemanticLayerCompute();
|
||||
}
|
||||
if (!options.cliVersion || !options.runtimeInstallPolicy || !options.io) {
|
||||
throw new Error('Managed Python semantic compute requires cliVersion, runtimeInstallPolicy, and io.');
|
||||
}
|
||||
const createManagedSemanticLayerCompute =
|
||||
deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort;
|
||||
return createManagedSemanticLayerCompute({
|
||||
cliVersion: options.cliVersion,
|
||||
installPolicy: options.runtimeInstallPolicy,
|
||||
io: options.io,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createKtxAgentRuntime(
|
||||
options: KtxAgentRuntimeOptions,
|
||||
deps: KtxAgentRuntimeDeps = {},
|
||||
): Promise<KtxAgentRuntime> {
|
||||
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: options.projectDir });
|
||||
const semanticLayerCompute = await createAgentSemanticLayerCompute(options, deps);
|
||||
const queryExecutor = options.enableQueryExecution
|
||||
? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)()
|
||||
: undefined;
|
||||
const ports = (deps.createContextTools ?? createLocalProjectMcpContextPorts)(project, {
|
||||
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
|
||||
...(queryExecutor ? { queryExecutor } : {}),
|
||||
});
|
||||
return {
|
||||
project,
|
||||
ports,
|
||||
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
|
||||
...(queryExecutor ? { queryExecutor } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isMissingProjectConfigError,
|
||||
missingConnectionSlSearchReadiness,
|
||||
missingProjectSlSearchReadiness,
|
||||
noConnectionsSlSearchReadiness,
|
||||
noIndexedSourcesSlSearchReadiness,
|
||||
} from './agent-search-readiness.js';
|
||||
|
||||
describe('agent semantic-layer search readiness guidance', () => {
|
||||
it('formats missing project guidance with exact recovery commands', () => {
|
||||
expect(missingProjectSlSearchReadiness('/tmp/ktx-search', 'gross revenue')).toEqual({
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: 'Semantic-layer search needs an initialized KTX project at /tmp/ktx-search.',
|
||||
nextSteps: [
|
||||
'ktx setup --project-dir /tmp/ktx-search',
|
||||
'ktx status --project-dir /tmp/ktx-search',
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
'ktx agent sl list --json --query "gross revenue" --project-dir /tmp/ktx-search',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('formats no-connection and no-index guidance without hiding the project path', () => {
|
||||
expect(noConnectionsSlSearchReadiness('/tmp/ktx-search', 'revenue')).toMatchObject({
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: 'Semantic-layer search found no configured connections in /tmp/ktx-search.',
|
||||
});
|
||||
expect(noIndexedSourcesSlSearchReadiness('/tmp/ktx-search', 'orders')).toMatchObject({
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: 'Semantic-layer search found no indexed semantic-layer sources in /tmp/ktx-search.',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats unknown connection guidance', () => {
|
||||
expect(missingConnectionSlSearchReadiness('/tmp/ktx-search', 'warehouse', 'revenue')).toMatchObject({
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: 'Semantic-layer search connection "warehouse" is not configured in /tmp/ktx-search.',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects missing ktx.yaml read errors', () => {
|
||||
const error = Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
path: '/tmp/ktx-search/ktx.yaml',
|
||||
});
|
||||
|
||||
expect(isMissingProjectConfigError(error)).toBe(true);
|
||||
expect(isMissingProjectConfigError(new Error('other'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
export type KtxAgentSlSearchReadinessCode =
|
||||
| 'agent_sl_search_missing_project'
|
||||
| 'agent_sl_search_no_connections'
|
||||
| 'agent_sl_search_unknown_connection'
|
||||
| 'agent_sl_search_no_indexed_sources';
|
||||
|
||||
export interface KtxAgentSlSearchReadinessDetail {
|
||||
code: KtxAgentSlSearchReadinessCode;
|
||||
message: string;
|
||||
nextSteps: string[];
|
||||
}
|
||||
|
||||
function queryForCommand(query: string | undefined): string {
|
||||
const trimmed = query?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : 'revenue';
|
||||
}
|
||||
|
||||
function projectSearchCommand(projectDir: string, query: string | undefined): string {
|
||||
return `ktx agent sl list --json --query ${JSON.stringify(queryForCommand(query))} --project-dir ${projectDir}`;
|
||||
}
|
||||
|
||||
function baseNextSteps(projectDir: string, query: string | undefined): string[] {
|
||||
return [
|
||||
`ktx setup --project-dir ${projectDir}`,
|
||||
`ktx status --project-dir ${projectDir}`,
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
projectSearchCommand(projectDir, query),
|
||||
];
|
||||
}
|
||||
|
||||
export function missingProjectSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KtxAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KTX project at ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function noConnectionsSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KtxAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: `Semantic-layer search found no configured connections in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function missingConnectionSlSearchReadiness(
|
||||
projectDir: string,
|
||||
connectionId: string,
|
||||
query: string | undefined,
|
||||
): KtxAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: `Semantic-layer search connection "${connectionId}" is not configured in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function noIndexedSourcesSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KtxAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: `Semantic-layer search found no indexed semantic-layer sources in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
function errorPath(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('path' in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const path = (error as { path?: unknown }).path;
|
||||
return typeof path === 'string' ? path : undefined;
|
||||
}
|
||||
|
||||
export function isMissingProjectConfigError(error: unknown): boolean {
|
||||
return errorCode(error) === 'ENOENT' && (errorPath(error)?.endsWith('ktx.yaml') ?? false);
|
||||
}
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildDefaultKtxProjectConfig } from '@ktx/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxAgent } from './agent.js';
|
||||
import type { KtxAgentRuntime } from './agent-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => (stdout += chunk) },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(overrides: Record<string, unknown> = {}): KtxAgentRuntime {
|
||||
const config = buildDefaultKtxProjectConfig('revenue');
|
||||
return {
|
||||
project: {
|
||||
projectDir: '/tmp/revenue',
|
||||
configPath: '/tmp/revenue/ktx.yaml',
|
||||
config: {
|
||||
...config,
|
||||
connections: {
|
||||
warehouse: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true as const },
|
||||
},
|
||||
},
|
||||
coreConfig: {} as KtxAgentRuntime['project']['coreConfig'],
|
||||
git: {} as KtxAgentRuntime['project']['git'],
|
||||
fileStore: {} as KtxAgentRuntime['project']['fileStore'],
|
||||
},
|
||||
ports: {
|
||||
connections: { list: vi.fn(async () => [{ id: 'warehouse', name: 'warehouse', connectionType: 'sqlite' }]) },
|
||||
semanticLayer: {
|
||||
listSources: vi.fn(async () => ({
|
||||
sources: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
connectionName: 'warehouse',
|
||||
name: 'orders',
|
||||
columnCount: 2,
|
||||
measureCount: 1,
|
||||
joinCount: 0,
|
||||
},
|
||||
],
|
||||
totalSources: 1,
|
||||
})),
|
||||
readSource: vi.fn(async () => ({ sourceName: 'orders', yaml: 'name: orders\n' })),
|
||||
writeSource: vi.fn(async () => ({ success: true, sourceName: 'orders' })),
|
||||
validate: vi.fn(async () => ({ success: true, errors: [], warnings: [] })),
|
||||
query: vi.fn(async () => ({ sql: 'select 1', headers: ['x'], rows: [[1]], totalRows: 1, plan: {} })),
|
||||
},
|
||||
knowledge: {
|
||||
search: vi.fn(async () => ({
|
||||
results: [
|
||||
{
|
||||
key: 'page-1',
|
||||
path: 'knowledge/global/page-1.md',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue logic',
|
||||
score: 0.9,
|
||||
matchReasons: ['lexical' as const],
|
||||
},
|
||||
],
|
||||
totalFound: 1,
|
||||
})),
|
||||
read: vi.fn(async () => ({
|
||||
key: 'page-1',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue logic',
|
||||
content: 'Use net revenue.',
|
||||
})),
|
||||
write: vi.fn(async () => ({ success: true, key: 'page-1', action: 'created' as const })),
|
||||
},
|
||||
},
|
||||
queryExecutor: {
|
||||
execute: vi.fn(async () => ({ headers: ['x'], rows: [[1]], totalRows: 1, command: 'SELECT', rowCount: 1 })),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeWithoutConnections(): KtxAgentRuntime {
|
||||
const base = runtime();
|
||||
return {
|
||||
...base,
|
||||
project: {
|
||||
...base.project,
|
||||
config: {
|
||||
...base.project.config,
|
||||
connections: {},
|
||||
},
|
||||
},
|
||||
ports: {
|
||||
...base.ports,
|
||||
semanticLayer: {
|
||||
...base.ports.semanticLayer!,
|
||||
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKtxAgent', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-agent-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('prints tool discovery with every stable command', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKtxAgent({ command: 'tools', projectDir: tempDir, json: true }, io.io)).resolves.toBe(0);
|
||||
|
||||
const body = JSON.parse(io.stdout());
|
||||
expect(body.projectDir).toBe(tempDir);
|
||||
expect(body.tools.map((tool: { name: string }) => tool.name)).toEqual([
|
||||
'context',
|
||||
'sl.list',
|
||||
'sl.read',
|
||||
'sl.query',
|
||||
'wiki.search',
|
||||
'wiki.read',
|
||||
'sql.execute',
|
||||
]);
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints project context from setup status, connections, and SL summaries', async () => {
|
||||
const io = makeIo();
|
||||
const createRuntime = vi.fn(async () => runtime());
|
||||
const readSetupStatus = vi.fn(async () => ({ project: { path: tempDir, ready: true }, agents: [] }));
|
||||
|
||||
await expect(
|
||||
runKtxAgent({ command: 'context', projectDir: tempDir, json: true }, io.io, { createRuntime, readSetupStatus }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({
|
||||
projectDir: tempDir,
|
||||
status: { project: { ready: true } },
|
||||
connections: [{ id: 'warehouse' }],
|
||||
semanticLayer: { totalSources: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches SL list, SL read, wiki search, and wiki read through local ports', async () => {
|
||||
for (const args of [
|
||||
{ command: 'sl-list' as const, projectDir: tempDir, json: true as const, connectionId: 'warehouse' },
|
||||
{
|
||||
command: 'sl-read' as const,
|
||||
projectDir: tempDir,
|
||||
json: true as const,
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
},
|
||||
{ command: 'wiki-search' as const, projectDir: tempDir, json: true as const, query: 'revenue', limit: 10 },
|
||||
{ command: 'wiki-read' as const, projectDir: tempDir, json: true as const, pageId: 'page-1' },
|
||||
]) {
|
||||
const io = makeIo();
|
||||
await expect(runKtxAgent(args, io.io, { createRuntime: async () => runtime() })).resolves.toBe(0);
|
||||
expect(JSON.parse(io.stdout())).toBeTruthy();
|
||||
expect(io.stderr()).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const knowledge = fakeRuntime.ports.knowledge;
|
||||
if (!knowledge) {
|
||||
throw new Error('Expected runtime knowledge port');
|
||||
}
|
||||
fakeRuntime.ports.knowledge = {
|
||||
...knowledge,
|
||||
search: vi.fn(async () => ({
|
||||
results: [
|
||||
{
|
||||
key: 'metrics-revenue',
|
||||
path: 'knowledge/global/metrics-revenue.md',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue metric definition',
|
||||
score: 0.02459016393442623,
|
||||
matchReasons: ['lexical' as const, 'token' as const],
|
||||
},
|
||||
],
|
||||
totalFound: 1,
|
||||
})),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxAgent({ command: 'wiki-search', projectDir: tempDir, json: true, query: 'paid order', limit: 5 }, io.io, {
|
||||
createRuntime: async () => fakeRuntime,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toEqual({
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
key: 'metrics-revenue',
|
||||
path: 'knowledge/global/metrics-revenue.md',
|
||||
matchReasons: ['lexical', 'token'],
|
||||
}),
|
||||
],
|
||||
totalFound: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('executes SL queries from a JSON query file', async () => {
|
||||
const queryFile = join(tempDir, 'sl-query.json');
|
||||
const io = makeIo();
|
||||
await writeFile(queryFile, '{"measures":["total_revenue"],"dimensions":[]}', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile,
|
||||
execute: true,
|
||||
maxRows: 100,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'never',
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime: async () => runtime() },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({ sql: 'select 1', rows: [[1]] });
|
||||
});
|
||||
|
||||
it('passes managed runtime options into default SL query runtime creation', async () => {
|
||||
const queryFile = join(tempDir, 'sl-query.json');
|
||||
const io = makeIo();
|
||||
const createRuntime = vi.fn(async () => runtime());
|
||||
await writeFile(queryFile, '{"measures":["total_revenue"],"dimensions":[]}', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile,
|
||||
execute: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createRuntime).toHaveBeenCalledWith({
|
||||
projectDir: tempDir,
|
||||
enableSemanticCompute: true,
|
||||
enableQueryExecution: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
io: io.io,
|
||||
});
|
||||
});
|
||||
|
||||
it('executes read-only SQL from a SQL file with an explicit row limit', async () => {
|
||||
const sqlFile = join(tempDir, 'query.sql');
|
||||
const fakeRuntime = runtime();
|
||||
const io = makeIo();
|
||||
await writeFile(sqlFile, 'select 1', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{
|
||||
command: 'sql-execute',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
sqlFile,
|
||||
maxRows: 100,
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime: async () => fakeRuntime as never },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(fakeRuntime.queryExecutor?.execute).toHaveBeenCalledWith({
|
||||
connectionId: 'warehouse',
|
||||
projectDir: '/tmp/revenue',
|
||||
connection: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true },
|
||||
sql: 'select 1',
|
||||
maxRows: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search runs outside a project', async () => {
|
||||
const io = makeIo();
|
||||
const missingProjectError = Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
path: join(tempDir, 'ktx.yaml'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'gross revenue' },
|
||||
io.io,
|
||||
{ createRuntime: vi.fn(async () => Promise.reject(missingProjectError)) },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KTX project at ${tempDir}.`,
|
||||
nextSteps: [
|
||||
`ktx setup --project-dir ${tempDir}`,
|
||||
`ktx status --project-dir ${tempDir}`,
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
`ktx agent sl list --json --query "gross revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(io.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search has no configured connections', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => runtimeWithoutConnections() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: `Semantic-layer search found no configured connections in ${tempDir}.`,
|
||||
nextSteps: [
|
||||
`ktx setup --project-dir ${tempDir}`,
|
||||
`ktx status --project-dir ${tempDir}`,
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
`ktx agent sl list --json --query "revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search asks for an unknown connection', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'missing', query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => runtime() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: `Semantic-layer search connection "missing" is not configured in ${tempDir}.`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search has no indexed sources', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const semanticLayer = fakeRuntime.ports.semanticLayer!;
|
||||
fakeRuntime.ports.semanticLayer = {
|
||||
...semanticLayer,
|
||||
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => fakeRuntime },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: `Semantic-layer search found no indexed semantic-layer sources in ${tempDir}.`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns JSON errors when required ports or records are missing', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxAgent({ command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'missing' }, io.io, {
|
||||
createRuntime: async () =>
|
||||
runtime({
|
||||
ports: { knowledge: { read: vi.fn(async () => null) } },
|
||||
}) as never,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('missing') },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
createKtxAgentRuntime,
|
||||
parseAgentMaxRows,
|
||||
readAgentJsonFile,
|
||||
writeAgentJson,
|
||||
writeAgentJsonError,
|
||||
type KtxAgentRuntime,
|
||||
type KtxAgentRuntimeDeps,
|
||||
} from './agent-runtime.js';
|
||||
import {
|
||||
isMissingProjectConfigError,
|
||||
missingConnectionSlSearchReadiness,
|
||||
missingProjectSlSearchReadiness,
|
||||
noConnectionsSlSearchReadiness,
|
||||
noIndexedSourcesSlSearchReadiness,
|
||||
type KtxAgentSlSearchReadinessDetail,
|
||||
} from './agent-search-readiness.js';
|
||||
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
||||
import { readKtxSetupStatus, type KtxSetupStatus } from './setup.js';
|
||||
|
||||
export type KtxAgentArgs =
|
||||
| { command: 'tools'; projectDir: string; json: true }
|
||||
| { command: 'context'; projectDir: string; json: true }
|
||||
| { command: 'sl-list'; projectDir: string; json: true; connectionId?: string; query?: string }
|
||||
| { command: 'sl-read'; projectDir: string; json: true; connectionId?: string; sourceName: string }
|
||||
| {
|
||||
command: 'sl-query';
|
||||
projectDir: string;
|
||||
json: true;
|
||||
connectionId: string;
|
||||
queryFile: string;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
cliVersion: string;
|
||||
runtimeInstallPolicy: KtxManagedPythonInstallPolicy;
|
||||
}
|
||||
| { command: 'wiki-search'; projectDir: string; json: true; query: string; limit: number }
|
||||
| { command: 'wiki-read'; projectDir: string; json: true; pageId: string }
|
||||
| { command: 'sql-execute'; projectDir: string; json: true; connectionId: string; sqlFile: string; maxRows?: number };
|
||||
|
||||
export interface KtxAgentDeps extends KtxAgentRuntimeDeps {
|
||||
createRuntime?: (options: {
|
||||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
cliVersion?: string;
|
||||
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
||||
io?: KtxCliIo;
|
||||
}) => Promise<KtxAgentRuntime>;
|
||||
readSetupStatus?: (
|
||||
projectDir: string,
|
||||
) => Promise<KtxSetupStatus | { project: { path?: string; ready: boolean }; agents: unknown[] }>;
|
||||
}
|
||||
|
||||
const AGENT_TOOLS = [
|
||||
{ name: 'context', command: 'ktx agent context --json' },
|
||||
{ name: 'sl.list', command: 'ktx agent sl list --json [--connection-id <id>] [--query <text>]' },
|
||||
{ name: 'sl.read', command: 'ktx agent sl read <sourceName> --json [--connection-id <id>]' },
|
||||
{
|
||||
name: 'sl.query',
|
||||
command: 'ktx agent sl query --json --connection-id <id> --query-file <path> --execute --max-rows 100',
|
||||
},
|
||||
{ name: 'wiki.search', command: 'ktx agent wiki search <query> --json [--limit 10]' },
|
||||
{ name: 'wiki.read', command: 'ktx agent wiki read <pageId> --json' },
|
||||
{
|
||||
name: 'sql.execute',
|
||||
command: 'ktx agent sql execute --json --connection-id <id> --sql-file <path> --max-rows 100',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function writeAgentSlSearchReadinessError(io: KtxCliIo, detail: KtxAgentSlSearchReadinessDetail): void {
|
||||
writeAgentJsonError(io, detail.message, { code: detail.code, nextSteps: detail.nextSteps });
|
||||
}
|
||||
|
||||
async function runtimeFor(args: KtxAgentArgs, deps: KtxAgentDeps, io: KtxCliIo): Promise<KtxAgentRuntime> {
|
||||
const needsSemanticCompute = args.command === 'sl-query';
|
||||
const needsQueryExecution = args.command === 'sql-execute' || (args.command === 'sl-query' && args.execute);
|
||||
const runtimeOptions = {
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
...(args.command === 'sl-query'
|
||||
? {
|
||||
cliVersion: args.cliVersion,
|
||||
runtimeInstallPolicy: args.runtimeInstallPolicy,
|
||||
io,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return deps.createRuntime ? deps.createRuntime(runtimeOptions) : createKtxAgentRuntime(runtimeOptions, deps);
|
||||
}
|
||||
|
||||
function connectionIdForSource(runtime: KtxAgentRuntime, requested: string | undefined): string {
|
||||
if (requested) return requested;
|
||||
const ids = Object.keys(runtime.project.config.connections ?? {});
|
||||
if (ids.length === 1) return ids[0] as string;
|
||||
throw new Error('Use --connection-id when the project has zero or multiple connections.');
|
||||
}
|
||||
|
||||
export async function runKtxAgent(args: KtxAgentArgs, io: KtxCliIo, deps: KtxAgentDeps = {}): Promise<number> {
|
||||
try {
|
||||
if (args.command === 'tools') {
|
||||
writeAgentJson(io, { projectDir: args.projectDir, tools: AGENT_TOOLS });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const runtime = await runtimeFor(args, deps, io);
|
||||
|
||||
if (args.command === 'context') {
|
||||
const [status, connections, semanticLayer] = await Promise.all([
|
||||
(deps.readSetupStatus ?? readKtxSetupStatus)(args.projectDir),
|
||||
runtime.ports.connections?.list() ?? [],
|
||||
runtime.ports.semanticLayer?.listSources({}) ?? { sources: [], totalSources: 0 },
|
||||
]);
|
||||
writeAgentJson(io, { projectDir: args.projectDir, status, connections, semanticLayer, tools: AGENT_TOOLS });
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-list') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
if (args.query) {
|
||||
const connectionIds = Object.keys(runtime.project.config.connections ?? {});
|
||||
if (args.connectionId && !runtime.project.config.connections[args.connectionId]) {
|
||||
writeAgentSlSearchReadinessError(
|
||||
io,
|
||||
missingConnectionSlSearchReadiness(args.projectDir, args.connectionId, args.query),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (connectionIds.length === 0) {
|
||||
writeAgentSlSearchReadinessError(io, noConnectionsSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const listed = await semanticLayer.listSources({ connectionId: args.connectionId, query: args.query });
|
||||
if (args.query && listed.sources.length === 0) {
|
||||
const allSources = await semanticLayer.listSources({ connectionId: args.connectionId });
|
||||
if (allSources.totalSources === 0) {
|
||||
writeAgentSlSearchReadinessError(io, noIndexedSourcesSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
writeAgentJson(io, listed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-read') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
const source = await semanticLayer.readSource({
|
||||
connectionId: connectionIdForSource(runtime, args.connectionId),
|
||||
sourceName: args.sourceName,
|
||||
});
|
||||
if (!source) throw new Error(`Semantic-layer source "${args.sourceName}" was not found.`);
|
||||
writeAgentJson(io, source);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-query') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
const query = await readAgentJsonFile(args.queryFile);
|
||||
const maxRows = args.execute ? parseAgentMaxRows(args.maxRows) : args.maxRows;
|
||||
writeAgentJson(
|
||||
io,
|
||||
await semanticLayer.query({
|
||||
connectionId: args.connectionId,
|
||||
query: { ...query, ...(maxRows !== undefined ? { limit: maxRows } : {}) } as never,
|
||||
}),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'wiki-search') {
|
||||
const knowledge = runtime.ports.knowledge;
|
||||
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
|
||||
writeAgentJson(io, await knowledge.search({ userId: 'agent', query: args.query, limit: args.limit }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'wiki-read') {
|
||||
const knowledge = runtime.ports.knowledge;
|
||||
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
|
||||
const page = await knowledge.read({ userId: 'agent', key: args.pageId });
|
||||
if (!page) throw new Error(`Wiki page "${args.pageId}" was not found.`);
|
||||
writeAgentJson(io, page);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const queryExecutor = runtime.queryExecutor;
|
||||
if (!queryExecutor) throw new Error('SQL execution is not available for this project.');
|
||||
const connection = runtime.project.config.connections[args.connectionId];
|
||||
if (!connection) throw new Error(`Connection "${args.connectionId}" was not found.`);
|
||||
const maxRows = parseAgentMaxRows(args.maxRows);
|
||||
writeAgentJson(
|
||||
io,
|
||||
await queryExecutor.execute({
|
||||
connectionId: args.connectionId,
|
||||
projectDir: runtime.project.projectDir,
|
||||
connection,
|
||||
sql: await readFile(args.sqlFile, 'utf-8'),
|
||||
maxRows,
|
||||
}),
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (args.command === 'sl-list' && args.query && isMissingProjectConfigError(error)) {
|
||||
writeAgentSlSearchReadinessError(io, missingProjectSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
writeAgentJsonError(io, error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from './cli-runtime.js';
|
||||
import { registerAgentCommands } from './commands/agent-commands.js';
|
||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerIngestCommands } from './commands/ingest-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
|
|
@ -321,7 +320,6 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
|
|||
registerWikiCommands(program, context);
|
||||
registerSlCommands(program, context);
|
||||
registerStatusCommands(program, context);
|
||||
registerAgentCommands(program, context);
|
||||
registerDevCommands(program, context);
|
||||
|
||||
return program;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { createRequire } from 'node:module';
|
|||
|
||||
import type { KtxConnectionMetabaseSetupArgs } from './commands/connection-metabase-setup.js';
|
||||
import type { KtxConnectionNotionArgs } from './commands/connection-notion.js';
|
||||
import type { KtxAgentArgs } from './agent.js';
|
||||
import type { KtxConnectionArgs } from './connection.js';
|
||||
import type { KtxDoctorArgs } from './doctor.js';
|
||||
import type { KtxIngestArgs } from './ingest.js';
|
||||
|
|
@ -30,7 +29,6 @@ export interface KtxCliIo {
|
|||
|
||||
export interface KtxCliDeps {
|
||||
setup?: (args: KtxSetupArgs, io: KtxCliIo) => Promise<number>;
|
||||
agent?: (args: KtxAgentArgs, io: KtxCliIo) => Promise<number>;
|
||||
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
|
||||
connectionNotion?: (args: KtxConnectionNotionArgs, io: KtxCliIo) => Promise<number>;
|
||||
connectionMetabaseSetup?: (args: KtxConnectionMetabaseSetupArgs, io: KtxCliIo) => Promise<number>;
|
||||
|
|
|
|||
|
|
@ -53,15 +53,18 @@ export const slQueryCommandSchema = z.object({
|
|||
command: z.literal('query'),
|
||||
projectDir: projectDirSchema,
|
||||
connectionId: z.string().min(1).optional(),
|
||||
query: z.object({
|
||||
measures: z.array(z.string().min(1)).min(1),
|
||||
dimensions: stringArraySchema,
|
||||
filters: stringArraySchema.optional(),
|
||||
segments: stringArraySchema.optional(),
|
||||
order_by: z.array(orderBySchema).optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
include_empty: z.literal(true).optional(),
|
||||
}),
|
||||
query: z
|
||||
.object({
|
||||
measures: z.array(z.string().min(1)).min(1),
|
||||
dimensions: stringArraySchema,
|
||||
filters: stringArraySchema.optional(),
|
||||
segments: stringArraySchema.optional(),
|
||||
order_by: z.array(orderBySchema).optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
include_empty: z.literal(true).optional(),
|
||||
})
|
||||
.optional(),
|
||||
queryFile: z.string().min(1).optional(),
|
||||
format: z.enum(['json', 'sql']),
|
||||
execute: z.boolean(),
|
||||
cliVersion: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import { Option, type Command } from '@commander-js/extra-typings';
|
||||
import type { KtxAgentArgs } from '../agent.js';
|
||||
import type { KtxCliCommandContext } from '../cli-program.js';
|
||||
import { parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
||||
|
||||
async function runAgent(context: KtxCliCommandContext, args: KtxAgentArgs): Promise<void> {
|
||||
const runner = context.deps.agent ?? (await import('../agent.js')).runKtxAgent;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
function jsonOption(): Option {
|
||||
return new Option('--json', 'Print JSON output').makeOptionMandatory();
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const agent = program
|
||||
.command('agent', { hidden: true })
|
||||
.description('Machine-readable KTX commands for coding agents')
|
||||
.showHelpAfterError();
|
||||
|
||||
agent.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('agent', actionCommand);
|
||||
});
|
||||
|
||||
agent
|
||||
.command('tools')
|
||||
.description('Print available agent-facing KTX tools')
|
||||
.addOption(jsonOption())
|
||||
.action(async (_options, command) => {
|
||||
await runAgent(context, { command: 'tools', projectDir: resolveCommandProjectDir(command), json: true });
|
||||
});
|
||||
|
||||
agent
|
||||
.command('context')
|
||||
.description('Print project context for agent planning')
|
||||
.addOption(jsonOption())
|
||||
.action(async (_options, command) => {
|
||||
await runAgent(context, { command: 'context', projectDir: resolveCommandProjectDir(command), json: true });
|
||||
});
|
||||
|
||||
const sl = agent.command('sl').description('Semantic-layer agent commands');
|
||||
sl.command('list')
|
||||
.description('List semantic-layer sources')
|
||||
.addOption(jsonOption())
|
||||
.option('--connection-id <id>', 'Filter by connection id')
|
||||
.option('--query <text>', 'Search source names and descriptions')
|
||||
.action(async (options: { connectionId?: string; query?: string }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
...(options.query ? { query: options.query } : {}),
|
||||
});
|
||||
});
|
||||
sl.command('read')
|
||||
.description('Read one semantic-layer source')
|
||||
.argument('<sourceName>')
|
||||
.addOption(jsonOption())
|
||||
.option('--connection-id <id>', 'Connection id containing the source')
|
||||
.action(async (sourceName: string, options: { connectionId?: string }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
sourceName,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
});
|
||||
});
|
||||
sl.command('query')
|
||||
.description('Run a semantic-layer query JSON file')
|
||||
.addOption(jsonOption())
|
||||
.requiredOption('--connection-id <id>', 'Connection id for execution')
|
||||
.requiredOption('--query-file <path>', 'JSON semantic-layer query file')
|
||||
.option('--execute', 'Execute the compiled query against the connection', false)
|
||||
.option('--yes', 'Install the managed Python runtime without prompting when required', false)
|
||||
.option('--no-input', 'Disable interactive managed runtime installation')
|
||||
.option('--max-rows <number>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||
.action(
|
||||
async (
|
||||
options: {
|
||||
connectionId: string;
|
||||
queryFile: string;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
yes?: boolean;
|
||||
input?: boolean;
|
||||
},
|
||||
command,
|
||||
) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-query',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
connectionId: options.connectionId,
|
||||
queryFile: options.queryFile,
|
||||
execute: options.execute,
|
||||
cliVersion: context.packageInfo.version,
|
||||
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
|
||||
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const wiki = agent.command('wiki').description('KTX wiki agent commands');
|
||||
wiki
|
||||
.command('search')
|
||||
.description('Search KTX wiki pages')
|
||||
.argument('<query>')
|
||||
.addOption(jsonOption())
|
||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption, 10)
|
||||
.action(async (query: string, options: { limit: number }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'wiki-search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
query,
|
||||
limit: options.limit,
|
||||
});
|
||||
});
|
||||
wiki
|
||||
.command('read')
|
||||
.description('Read one KTX wiki page')
|
||||
.argument('<pageId>')
|
||||
.addOption(jsonOption())
|
||||
.action(async (pageId: string, _options, command) => {
|
||||
await runAgent(context, { command: 'wiki-read', projectDir: resolveCommandProjectDir(command), json: true, pageId });
|
||||
});
|
||||
|
||||
const sql = agent.command('sql').description('Safe SQL execution commands');
|
||||
sql
|
||||
.command('execute')
|
||||
.description('Execute read-only SQL with a row limit')
|
||||
.addOption(jsonOption())
|
||||
.requiredOption('--connection-id <id>', 'Connection id for execution')
|
||||
.requiredOption('--sql-file <path>', 'SQL file to execute')
|
||||
.requiredOption('--max-rows <number>', 'Maximum rows to return', parsePositiveIntegerOption)
|
||||
.action(async (options: { connectionId: string; sqlFile: string; maxRows: number }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sql-execute',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
connectionId: options.connectionId,
|
||||
sqlFile: options.sqlFile,
|
||||
maxRows: options.maxRows,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
|
|||
registerConnectionNotionCommands(connection, context);
|
||||
}
|
||||
|
||||
export function registerConnectionMappingCommands(connection: Command, context: KtxCliCommandContext): void {
|
||||
function registerConnectionMappingCommands(connection: Command, context: KtxCliCommandContext): void {
|
||||
const mapping = connection
|
||||
.command('mapping')
|
||||
.description('Manage Metabase warehouse mappings')
|
||||
|
|
|
|||
|
|
@ -369,14 +369,6 @@ function setExpanded(state: PickerState, nodeId: string, value: boolean | 'toggl
|
|||
return cloneState(state, { expanded });
|
||||
}
|
||||
|
||||
function expandPath(state: PickerState, nodeId: string): PickerState {
|
||||
const expanded = new Set(state.expanded);
|
||||
for (const ancestorId of ancestorsOf(nodeId, state.byId)) {
|
||||
expanded.add(ancestorId);
|
||||
}
|
||||
return cloneState(state, { expanded });
|
||||
}
|
||||
|
||||
export function moveCursor(state: PickerState, dir: 'up' | 'down' | 'left' | 'right'): PickerState {
|
||||
const node = state.byId.get(state.cursorId);
|
||||
if (!node) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* @jsxImportSource react */
|
||||
import { render as renderInkTest } from 'ink-testing-library';
|
||||
import React, { act, type ReactNode } from 'react';
|
||||
import { act, type ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './connection-notion-tree.js';
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* @jsxImportSource react */
|
||||
import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink';
|
||||
import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
filterTree,
|
||||
flattenSelection,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import { collectOption, type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import {
|
||||
collectOption,
|
||||
type KtxCliCommandContext,
|
||||
parsePositiveIntegerOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import { wikiWriteCommandSchema } from '../command-schemas.js';
|
||||
import type { KtxKnowledgeArgs } from '../knowledge.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
|
@ -24,12 +29,14 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
wiki
|
||||
.command('list')
|
||||
.description('List local wiki pages')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (options: { userId: string }, command) => {
|
||||
.action(async (options: { userId: string; json?: boolean }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
userId: options.userId,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -37,13 +44,15 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
.command('read')
|
||||
.description('Read one local wiki page')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (key: string, options: { userId: string }, command) => {
|
||||
.action(async (key: string, options: { userId: string; json?: boolean }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
userId: options.userId,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -51,13 +60,17 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
.command('search')
|
||||
.description('Search local wiki pages')
|
||||
.argument('<query>', 'Search query')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (query: string, options: { userId: string }, command) => {
|
||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
|
||||
.action(async (query: string, options: { userId: string; json?: boolean; limit?: number }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
query,
|
||||
userId: options.userId,
|
||||
json: options.json,
|
||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ async function runRuntimeArgs(context: KtxCliCommandContext, args: KtxRuntimeArg
|
|||
export function registerRuntimeCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const runtime = program
|
||||
.command('runtime')
|
||||
.description('Install, inspect, and prune the KTX-managed Python runtime')
|
||||
.description('Install, start, stop, and inspect the KTX-managed Python runtime')
|
||||
.showHelpAfterError();
|
||||
|
||||
runtime
|
||||
|
|
@ -64,7 +64,7 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
|
|||
|
||||
runtime
|
||||
.command('status')
|
||||
.description('Show managed Python runtime status')
|
||||
.description('Show managed Python runtime status and readiness checks')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
|
|
@ -73,18 +73,4 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
|
|||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
runtime
|
||||
.command('prune')
|
||||
.description('Remove stale managed Python runtimes for older CLI versions')
|
||||
.option('--dry-run', 'List stale runtimes without deleting them', false)
|
||||
.option('--yes', 'Confirm deletion of stale runtime directories', false)
|
||||
.action(async (options: { dryRun?: boolean; yes?: boolean }) => {
|
||||
await runRuntimeArgs(context, {
|
||||
command: 'prune',
|
||||
cliVersion: context.packageInfo.version,
|
||||
dryRun: options.dryRun === true,
|
||||
yes: options.yes === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
sl.command('list')
|
||||
.description('List semantic-layer sources')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.option('--query <text>', 'Search source names and descriptions')
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
|
|
@ -59,26 +60,34 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(async (options: { connectionId?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean }, command) => {
|
||||
.action(
|
||||
async (
|
||||
options: { connectionId?: string; query?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
|
||||
command,
|
||||
) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
query: options.query,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
sl.command('read')
|
||||
.description('Read a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.requiredOption('--connection-id <id>', 'KTX connection id')
|
||||
.action(async (sourceName: string, options: { connectionId: string }, command) => {
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (sourceName: string, options: { connectionId: string; json?: boolean }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
sourceName,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -113,6 +122,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
sl.command('query')
|
||||
.description('Compile or execute a semantic-layer query')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.option('--query-file <path>', 'JSON semantic-layer query file')
|
||||
.option('--measure <measure>', 'Measure to query; repeatable', collectOption, [])
|
||||
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
|
||||
.option('--filter <filter>', 'Filter expression; repeatable', collectOption, [])
|
||||
|
|
@ -126,22 +136,26 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
.option('--no-input', 'Disable interactive managed runtime installation')
|
||||
.option('--max-rows <n>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||
.action(async (options, command) => {
|
||||
if (options.measure.length === 0) {
|
||||
if (options.measure.length === 0 && !options.queryFile) {
|
||||
throw new Error('sl query requires at least one --measure');
|
||||
}
|
||||
const args = slQueryCommandSchema.parse({
|
||||
command: 'query',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
query: {
|
||||
measures: options.measure,
|
||||
dimensions: options.dimension,
|
||||
...(options.filter.length > 0 ? { filters: options.filter } : {}),
|
||||
...(options.segment.length > 0 ? { segments: options.segment } : {}),
|
||||
...(options.orderBy.length > 0 ? { order_by: options.orderBy } : {}),
|
||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
...(options.includeEmpty === true ? { include_empty: true } : {}),
|
||||
},
|
||||
...(options.queryFile
|
||||
? { queryFile: options.queryFile }
|
||||
: {
|
||||
query: {
|
||||
measures: options.measure,
|
||||
dimensions: options.dimension,
|
||||
...(options.filter.length > 0 ? { filters: options.filter } : {}),
|
||||
...(options.segment.length > 0 ? { segments: options.segment } : {}),
|
||||
...(options.orderBy.length > 0 ? { order_by: options.orderBy } : {}),
|
||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
...(options.includeEmpty === true ? { include_empty: true } : {}),
|
||||
},
|
||||
}),
|
||||
format: options.format,
|
||||
execute: options.execute === true,
|
||||
cliVersion: context.packageInfo.version,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ describe('dev Commander tree', () => {
|
|||
for (const argv of [
|
||||
['dev', 'doctor', 'setup'],
|
||||
['dev', 'runtime', 'doctor'],
|
||||
['dev', 'runtime', 'prune', '--dry-run'],
|
||||
['dev', 'scan', 'warehouse'],
|
||||
['dev', 'ingest', 'run'],
|
||||
['dev', 'mapping', 'list'],
|
||||
|
|
@ -126,7 +127,7 @@ describe('dev Commander tree', () => {
|
|||
it.each([
|
||||
{
|
||||
argv: ['dev', 'runtime', '--help'],
|
||||
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status', 'prune'],
|
||||
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status'],
|
||||
},
|
||||
{
|
||||
argv: ['scan', '--help'],
|
||||
|
|
@ -147,6 +148,10 @@ describe('dev Commander tree', () => {
|
|||
for (const text of expected) {
|
||||
expect(io.stdout()).toContain(text);
|
||||
}
|
||||
if (argv.join(' ') === 'dev runtime --help') {
|
||||
expect(io.stdout()).not.toContain('prune');
|
||||
expect(io.stdout()).not.toContain('doctor');
|
||||
}
|
||||
expect(io.stderr()).toBe('');
|
||||
expect(doctor).not.toHaveBeenCalled();
|
||||
expect(ingest).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -73,26 +73,27 @@ describe('standalone local warehouse example', () => {
|
|||
const projectDir = await copyExampleProject(tempDir);
|
||||
const sourceDir = join(projectDir, 'source');
|
||||
|
||||
const knowledgeList = await runBuiltCli(['agent', 'wiki', 'search', 'revenue', '--json', '--project-dir', projectDir]);
|
||||
const knowledgeList = await runBuiltCli(['wiki', 'search', 'revenue', '--json', '--project-dir', projectDir]);
|
||||
expect(knowledgeList).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(parseJsonOutput<{ results: Array<{ key: string; summary: string }> }>(knowledgeList.stdout).results).toContainEqual(
|
||||
expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }),
|
||||
);
|
||||
expect(
|
||||
parseJsonOutput<{ data: { items: Array<{ key: string; summary: string }> } }>(knowledgeList.stdout).data.items,
|
||||
).toContainEqual(expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }));
|
||||
|
||||
const knowledgeRead = await runBuiltCli(['agent', 'wiki', 'read', 'revenue', '--json', '--project-dir', projectDir]);
|
||||
const knowledgeRead = await runBuiltCli(['wiki', 'read', 'revenue', '--json', '--project-dir', projectDir]);
|
||||
expect(knowledgeRead).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(parseJsonOutput<{ content: string }>(knowledgeRead.stdout).content).toContain(
|
||||
expect(parseJsonOutput<{ data: { content: string } }>(knowledgeRead.stdout).data.content).toContain(
|
||||
'Revenue is paid order amount after refund adjustments.',
|
||||
);
|
||||
|
||||
const slList = await runBuiltCli(['agent', 'sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
|
||||
const slList = await runBuiltCli(['sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
|
||||
expect(slList).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(parseJsonOutput<{ sources: Array<{ connectionId: string; name: string; columnCount: number }> }>(slList.stdout).sources).toContainEqual(
|
||||
expect.objectContaining({ connectionId: 'warehouse', name: 'orders', columnCount: 3 }),
|
||||
);
|
||||
expect(
|
||||
parseJsonOutput<{ data: { items: Array<{ connectionId: string; name: string; columnCount: number }> } }>(
|
||||
slList.stdout,
|
||||
).data.items,
|
||||
).toContainEqual(expect.objectContaining({ connectionId: 'warehouse', name: 'orders', columnCount: 3 }));
|
||||
|
||||
const slRead = await runBuiltCli([
|
||||
'agent',
|
||||
'sl',
|
||||
'read',
|
||||
'orders',
|
||||
|
|
@ -103,7 +104,7 @@ describe('standalone local warehouse example', () => {
|
|||
projectDir,
|
||||
]);
|
||||
expect(slRead).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(parseJsonOutput<{ yaml: string }>(slRead.stdout).yaml).toContain('name: orders');
|
||||
expect(parseJsonOutput<{ data: { yaml: string } }>(slRead.stdout).data.yaml).toContain('name: orders');
|
||||
|
||||
const ingest = await runBuiltCli([
|
||||
'ingest',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
|
@ -159,7 +159,7 @@ describe('runKtxCli', () => {
|
|||
await expect(runKtxCli(['dev', 'runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(1);
|
||||
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
|
|
@ -208,19 +208,11 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
statusIo.io,
|
||||
);
|
||||
expect(runtime).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
{
|
||||
command: 'prune',
|
||||
cliVersion: '0.0.0-private',
|
||||
dryRun: true,
|
||||
yes: false,
|
||||
},
|
||||
pruneIo.io,
|
||||
);
|
||||
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo, pruneIo]) {
|
||||
expect(runtime).toHaveBeenCalledTimes(5);
|
||||
for (const io of [installIo, startIo, stopIo, stopAllIo, statusIo]) {
|
||||
expect(io.stderr()).toBe('');
|
||||
}
|
||||
expect(pruneIo.stderr()).toMatch(/unknown command|error:/);
|
||||
});
|
||||
|
||||
it('prints the resolved project directory for ordinary project commands', async () => {
|
||||
|
|
@ -1149,136 +1141,28 @@ describe('runKtxCli', () => {
|
|||
expect(setupIo.stderr()).toContain('Choose only one Historic SQL action');
|
||||
});
|
||||
|
||||
it('registers hidden agent help and tools discovery without showing agent in root help', async () => {
|
||||
const helpIo = makeIo();
|
||||
const toolsIo = makeIo();
|
||||
const agent = vi.fn(async () => 0);
|
||||
it('rejects the removed hidden agent command', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKtxCli(['agent', '--help'], helpIo.io, { agent })).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'agent', 'tools', '--json'], toolsIo.io, { agent }),
|
||||
).resolves.toBe(0);
|
||||
await expect(runKtxCli(['agent'], io.io)).resolves.toBe(1);
|
||||
|
||||
expect(helpIo.stdout()).toContain('Usage: ktx agent');
|
||||
expect(toolsIo.stderr()).toBe('');
|
||||
expect(agent).toHaveBeenCalledWith({ command: 'tools', projectDir: tempDir, json: true }, toolsIo.io);
|
||||
expect(io.stderr()).toContain("unknown command 'agent'");
|
||||
expect(io.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('dispatches full hidden agent commands without exposing agent in root help', async () => {
|
||||
const agent = vi.fn(async () => 0);
|
||||
const cases = [
|
||||
{
|
||||
argv: ['--project-dir', tempDir, 'agent', 'context', '--json'],
|
||||
args: { command: 'context', projectDir: tempDir, json: true },
|
||||
},
|
||||
{
|
||||
argv: [
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query',
|
||||
'orders',
|
||||
],
|
||||
args: { command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'orders' },
|
||||
},
|
||||
{
|
||||
argv: ['--project-dir', tempDir, 'agent', 'sl', 'read', 'orders', '--json', '--connection-id', 'warehouse'],
|
||||
args: { command: 'sl-read', projectDir: tempDir, json: true, sourceName: 'orders', connectionId: 'warehouse' },
|
||||
},
|
||||
{
|
||||
argv: [
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query-file',
|
||||
'/tmp/query.json',
|
||||
'--execute',
|
||||
'--max-rows',
|
||||
'100',
|
||||
],
|
||||
args: {
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile: '/tmp/query.json',
|
||||
execute: true,
|
||||
maxRows: 100,
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'prompt',
|
||||
},
|
||||
},
|
||||
{
|
||||
argv: ['--project-dir', tempDir, 'agent', 'wiki', 'search', 'revenue', '--json', '--limit', '5'],
|
||||
args: { command: 'wiki-search', projectDir: tempDir, json: true, query: 'revenue', limit: 5 },
|
||||
},
|
||||
{
|
||||
argv: ['--project-dir', tempDir, 'agent', 'wiki', 'read', 'page-1', '--json'],
|
||||
args: { command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'page-1' },
|
||||
},
|
||||
{
|
||||
argv: [
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sql',
|
||||
'execute',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--sql-file',
|
||||
'/tmp/query.sql',
|
||||
'--max-rows',
|
||||
'100',
|
||||
],
|
||||
args: {
|
||||
command: 'sql-execute',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
sqlFile: '/tmp/query.sql',
|
||||
maxRows: 100,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
const io = makeIo();
|
||||
await expect(runKtxCli(entry.argv, io.io, { agent })).resolves.toBe(0);
|
||||
expect(agent).toHaveBeenLastCalledWith(entry.args, io.io);
|
||||
expect(io.stderr()).toBe('');
|
||||
}
|
||||
|
||||
const helpIo = makeIo();
|
||||
await expect(runKtxCli(['--help'], helpIo.io, { agent })).resolves.toBe(0);
|
||||
expect(helpIo.stdout()).not.toContain('agent ');
|
||||
});
|
||||
|
||||
it('routes hidden agent SL query managed runtime policies', async () => {
|
||||
it('routes public SL query files with managed runtime policies', async () => {
|
||||
const autoIo = makeIo();
|
||||
const neverIo = makeIo();
|
||||
const conflictIo = makeIo();
|
||||
const agent = vi.fn(async () => 0);
|
||||
const sl = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query-file',
|
||||
|
|
@ -1286,7 +1170,7 @@ describe('runKtxCli', () => {
|
|||
'--yes',
|
||||
],
|
||||
autoIo.io,
|
||||
{ agent },
|
||||
{ sl },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
|
|
@ -1295,10 +1179,8 @@ describe('runKtxCli', () => {
|
|||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query-file',
|
||||
|
|
@ -1306,7 +1188,7 @@ describe('runKtxCli', () => {
|
|||
'--no-input',
|
||||
],
|
||||
neverIo.io,
|
||||
{ agent },
|
||||
{ sl },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
|
|
@ -1315,10 +1197,8 @@ describe('runKtxCli', () => {
|
|||
[
|
||||
'--project-dir',
|
||||
tempDir,
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
'warehouse',
|
||||
'--query-file',
|
||||
|
|
@ -1327,33 +1207,33 @@ describe('runKtxCli', () => {
|
|||
'--no-input',
|
||||
],
|
||||
conflictIo.io,
|
||||
{ agent },
|
||||
{ sl },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(agent).toHaveBeenNthCalledWith(
|
||||
expect(sl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
command: 'sl-query',
|
||||
command: 'query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile: '/tmp/query.json',
|
||||
execute: false,
|
||||
format: 'json',
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
autoIo.io,
|
||||
);
|
||||
expect(agent).toHaveBeenNthCalledWith(
|
||||
expect(sl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
command: 'sl-query',
|
||||
command: 'query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile: '/tmp/query.json',
|
||||
execute: false,
|
||||
format: 'json',
|
||||
cliVersion: '0.0.0-private',
|
||||
runtimeInstallPolicy: 'never',
|
||||
},
|
||||
|
|
@ -1362,112 +1242,6 @@ describe('runKtxCli', () => {
|
|||
expect(conflictIo.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
|
||||
});
|
||||
|
||||
it('prints semantic-layer hybrid search metadata from the hidden agent sl list command', async () => {
|
||||
const agent = vi.fn(async (args, io) => {
|
||||
expect(args).toEqual({
|
||||
command: 'sl-list',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
query: 'paid',
|
||||
});
|
||||
io.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
sources: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
connectionName: 'warehouse',
|
||||
name: 'orders',
|
||||
columnCount: 2,
|
||||
measureCount: 1,
|
||||
joinCount: 0,
|
||||
score: 0.03278688524590164,
|
||||
matchReasons: ['dictionary'],
|
||||
dictionaryMatches: [{ column: 'status', values: ['paid'] }],
|
||||
},
|
||||
],
|
||||
totalSources: 1,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return 0;
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'agent', 'sl', 'list', '--json', '--connection-id', 'warehouse', '--query', 'paid'],
|
||||
io.io,
|
||||
{ agent },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toEqual({
|
||||
sources: [
|
||||
expect.objectContaining({
|
||||
connectionId: 'warehouse',
|
||||
name: 'orders',
|
||||
matchReasons: ['dictionary'],
|
||||
dictionaryMatches: [{ column: 'status', values: ['paid'] }],
|
||||
}),
|
||||
],
|
||||
totalSources: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => {
|
||||
const agent = vi.fn(async (args, io) => {
|
||||
expect(args).toEqual({
|
||||
command: 'wiki-search',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
query: 'paid order',
|
||||
limit: 5,
|
||||
});
|
||||
io.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
results: [
|
||||
{
|
||||
key: 'metrics-revenue',
|
||||
path: 'knowledge/global/metrics-revenue.md',
|
||||
scope: 'GLOBAL',
|
||||
summary: 'Revenue metric definition',
|
||||
score: 0.02459016393442623,
|
||||
matchReasons: ['lexical', 'token'],
|
||||
},
|
||||
],
|
||||
totalFound: 1,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return 0;
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'agent', 'wiki', 'search', 'paid order', '--json', '--limit', '5'], io.io, {
|
||||
agent,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toEqual({
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
key: 'metrics-revenue',
|
||||
path: 'knowledge/global/metrics-revenue.md',
|
||||
matchReasons: ['lexical', 'token'],
|
||||
}),
|
||||
],
|
||||
totalFound: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches public connection subcommands through the existing connection implementation', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
|
||||
const connection = vi.fn(async () => 0);
|
||||
|
|
|
|||
|
|
@ -9,17 +9,6 @@ export {
|
|||
type KtxCliIo,
|
||||
type KtxCliPackageInfo,
|
||||
} from './cli-runtime.js';
|
||||
export { runKtxAgent, type KtxAgentArgs } from './agent.js';
|
||||
export {
|
||||
KTX_AGENT_MAX_ROWS_CAP,
|
||||
createKtxAgentRuntime,
|
||||
parseAgentMaxRows,
|
||||
readAgentJsonFile,
|
||||
writeAgentJson,
|
||||
writeAgentJsonError,
|
||||
type KtxAgentRuntime,
|
||||
type KtxAgentRuntimeDeps,
|
||||
} from './agent-runtime.js';
|
||||
export { runKtxSetup, type KtxSetupArgs, type KtxSetupStatus } from './setup.js';
|
||||
export type {
|
||||
KtxSetupDatabaseDriver,
|
||||
|
|
|
|||
86
packages/cli/src/ingest-query-executor.test.ts
Normal file
86
packages/cli/src/ingest-query-executor.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { KtxLocalProject } from '@ktx/context/project';
|
||||
import { createKtxConnectorCapabilities, type KtxScanConnector } from '@ktx/context/scan';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
||||
|
||||
function project(): KtxLocalProject {
|
||||
return {
|
||||
projectDir: '/tmp/ktx-query-project',
|
||||
config: {
|
||||
project: 'warehouse',
|
||||
connections: {
|
||||
warehouse: { driver: 'postgres', url: 'postgresql://readonly@example.test/db' },
|
||||
},
|
||||
},
|
||||
} as unknown as KtxLocalProject;
|
||||
}
|
||||
|
||||
function connector(overrides: Partial<KtxScanConnector> = {}): KtxScanConnector {
|
||||
return {
|
||||
id: 'warehouse',
|
||||
driver: 'postgres',
|
||||
capabilities: createKtxConnectorCapabilities({ readOnlySql: true }),
|
||||
async introspect() {
|
||||
throw new Error('introspect is not used by this test');
|
||||
},
|
||||
executeReadOnly: vi.fn(async () => ({
|
||||
headers: ['answer'],
|
||||
rows: [[1]],
|
||||
totalRows: 1,
|
||||
rowCount: 1,
|
||||
})),
|
||||
cleanup: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createKtxCliIngestQueryExecutor', () => {
|
||||
it('executes read-only SQL through the scan connector and cleans it up', async () => {
|
||||
const scanConnector = connector();
|
||||
const createConnector = vi.fn(async () => scanConnector);
|
||||
const executor = createKtxCliIngestQueryExecutor(project(), { createConnector });
|
||||
|
||||
await expect(
|
||||
executor.execute({
|
||||
connectionId: 'warehouse',
|
||||
connection: { driver: 'postgres', url: 'postgresql://readonly@example.test/db' },
|
||||
projectDir: '/tmp/ktx-query-project',
|
||||
sql: 'select 1',
|
||||
maxRows: 5,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
headers: ['answer'],
|
||||
rows: [[1]],
|
||||
totalRows: 1,
|
||||
command: 'SELECT',
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
expect(createConnector).toHaveBeenCalledWith(project(), 'warehouse');
|
||||
expect(scanConnector.executeReadOnly).toHaveBeenCalledWith(
|
||||
{ connectionId: 'warehouse', sql: 'select 1', maxRows: 5 },
|
||||
{ runId: 'ingest-sql-execution' },
|
||||
);
|
||||
expect(scanConnector.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects connectors without read-only SQL support', async () => {
|
||||
const scanConnector = connector({
|
||||
capabilities: createKtxConnectorCapabilities({ readOnlySql: false }),
|
||||
executeReadOnly: undefined,
|
||||
});
|
||||
const executor = createKtxCliIngestQueryExecutor(project(), {
|
||||
createConnector: vi.fn(async () => scanConnector),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute({
|
||||
connectionId: 'warehouse',
|
||||
connection: { driver: 'postgres' },
|
||||
projectDir: '/tmp/ktx-query-project',
|
||||
sql: 'select 1',
|
||||
}),
|
||||
).rejects.toThrow('Connection "warehouse" driver "postgres" does not support read-only SQL execution.');
|
||||
expect(scanConnector.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
49
packages/cli/src/ingest-query-executor.ts
Normal file
49
packages/cli/src/ingest-query-executor.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { KtxSqlQueryExecutionInput, KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||
import type { KtxLocalProject } from '@ktx/context/project';
|
||||
import type { KtxScanConnector, KtxScanContext } from '@ktx/context/scan';
|
||||
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
||||
|
||||
type CreateConnector = typeof createKtxCliScanConnector;
|
||||
|
||||
export interface KtxCliIngestQueryExecutorDeps {
|
||||
createConnector?: CreateConnector;
|
||||
}
|
||||
|
||||
async function cleanupConnector(connector: KtxScanConnector | null): Promise<void> {
|
||||
await connector?.cleanup?.();
|
||||
}
|
||||
|
||||
export function createKtxCliIngestQueryExecutor(
|
||||
project: KtxLocalProject,
|
||||
deps: KtxCliIngestQueryExecutorDeps = {},
|
||||
): KtxSqlQueryExecutorPort {
|
||||
const createConnector = deps.createConnector ?? createKtxCliScanConnector;
|
||||
return {
|
||||
async execute(input: KtxSqlQueryExecutionInput) {
|
||||
let connector: KtxScanConnector | null = null;
|
||||
try {
|
||||
connector = await createConnector(project, input.connectionId);
|
||||
if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) {
|
||||
throw new Error(
|
||||
`Connection "${input.connectionId}" driver "${connector.driver}" does not support read-only SQL execution.`,
|
||||
);
|
||||
}
|
||||
|
||||
const ctx: KtxScanContext = { runId: 'ingest-sql-execution' };
|
||||
const result = await connector.executeReadOnly(
|
||||
{ connectionId: input.connectionId, sql: input.sql, maxRows: input.maxRows },
|
||||
ctx,
|
||||
);
|
||||
return {
|
||||
headers: result.headers,
|
||||
rows: result.rows,
|
||||
totalRows: result.totalRows,
|
||||
command: 'SELECT',
|
||||
rowCount: result.rowCount,
|
||||
};
|
||||
} finally {
|
||||
await cleanupConnector(connector);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { AgentRunnerService, type RunLoopParams } from '@ktx/context/agent';
|
||||
import {
|
||||
LocalLookerRuntimeStore,
|
||||
KtxYamlMetabaseSourceStateReader,
|
||||
LocalMetabaseDiscoveryCache,
|
||||
MetabaseSourceAdapter,
|
||||
|
|
@ -13,12 +11,10 @@ import {
|
|||
type FetchContext,
|
||||
type IngestReportSnapshot,
|
||||
type LocalIngestResult,
|
||||
type LocalMetabaseFanoutProgress,
|
||||
type LookerMappingClient,
|
||||
type LookerRuntimeClient,
|
||||
type LookerTableIdentifierParser,
|
||||
type MemoryFlowEventSink,
|
||||
type MemoryFlowReplayInput,
|
||||
type MetabaseCard,
|
||||
type MetabaseCardSummary,
|
||||
type MetabaseClientFactory,
|
||||
|
|
@ -29,7 +25,7 @@ import {
|
|||
} from '@ktx/context/ingest';
|
||||
import { ktxLocalStateDbPath, loadKtxProject } from '@ktx/context/project';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { type KtxIngestArgs, runKtxIngest } from './ingest.js';
|
||||
import { runKtxIngest } from './ingest.js';
|
||||
|
||||
export function makeIo(
|
||||
options: {
|
||||
|
|
@ -266,6 +262,18 @@ export class CliLookerSlWritingAgentRunner extends AgentRunnerService {
|
|||
params.telemetryTags?.operationName === 'ingest-bundle-wu' &&
|
||||
params.telemetryTags?.unitKey === 'looker-explore-ecommerce-orders'
|
||||
) {
|
||||
const ledger = params.toolSet.record_verification_ledger;
|
||||
if (!ledger?.execute) {
|
||||
throw new Error('record_verification_ledger tool was not available to the Looker WorkUnit');
|
||||
}
|
||||
await ledger.execute(
|
||||
{
|
||||
summary: 'Test fixture verified Looker explore target identifiers before writing SL.',
|
||||
verifiedIdentifiers: ['prod-warehouse', 'public.orders'],
|
||||
unverifiedIdentifiers: [],
|
||||
},
|
||||
{ toolCallId: 'cli-looker-verification-ledger', messages: [] },
|
||||
);
|
||||
const slWrite = params.toolSet.sl_write_source;
|
||||
if (!slWrite?.execute) {
|
||||
throw new Error('sl_write_source tool was not available to the Looker WorkUnit');
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ import { join } from 'node:path';
|
|||
import {
|
||||
LocalLookerRuntimeStore,
|
||||
LocalMetabaseDiscoveryCache,
|
||||
getLocalIngestStatus,
|
||||
type LocalIngestResult,
|
||||
type LocalMetabaseFanoutProgress,
|
||||
type MemoryFlowReplayInput,
|
||||
type RunLocalIngestOptions,
|
||||
type SourceAdapter,
|
||||
} from '@ktx/context/ingest';
|
||||
|
|
@ -20,7 +18,6 @@ import {
|
|||
CliMetabaseAgentRunner,
|
||||
CliMetabaseSourceAdapter,
|
||||
completedLocalBundleRun,
|
||||
emitLiveLocalMemoryFlow,
|
||||
failedLocalBundleRun,
|
||||
localFakeBundleReport,
|
||||
makeCliLookerParser,
|
||||
|
|
@ -28,7 +25,6 @@ import {
|
|||
makeIo,
|
||||
persistLocalBundleReport,
|
||||
runPublicMetabaseSyncModeCase,
|
||||
writeBundleReportFile,
|
||||
writeMetabaseConfig,
|
||||
writeWarehouseConfig,
|
||||
} from './ingest.test-utils.js';
|
||||
|
|
@ -803,6 +799,44 @@ describe('runKtxIngest', () => {
|
|||
expect(runLocalIngest).toHaveBeenCalledWith(expect.objectContaining({ llmDebugRequestFile: debugFile }));
|
||||
});
|
||||
|
||||
it('supplies a scan-connector query executor to local ingest runs', async () => {
|
||||
const io = makeIo();
|
||||
const projectDir = join(tempDir, 'query-executor-project');
|
||||
await writeWarehouseConfig(projectDir);
|
||||
const queryExecutor = {
|
||||
execute: vi.fn(async () => ({
|
||||
headers: [],
|
||||
rows: [],
|
||||
totalRows: 0,
|
||||
command: 'SELECT',
|
||||
rowCount: 0,
|
||||
})),
|
||||
};
|
||||
const runLocalIngest = vi.fn(async (input: RunLocalIngestOptions): Promise<LocalIngestResult> =>
|
||||
completedLocalBundleRun(input, 'query-executor-run'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runKtxIngest(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'fake',
|
||||
outputMode: 'json',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
runLocalIngest,
|
||||
createAdapters: () => [],
|
||||
createQueryExecutor: () => queryExecutor,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runLocalIngest).toHaveBeenCalledWith(expect.objectContaining({ queryExecutor }));
|
||||
});
|
||||
|
||||
it('passes daemon database introspection URL to default local ingest adapters', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await writeWarehouseConfig(projectDir);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import {
|
|||
runLocalMetabaseIngest,
|
||||
savedMemoryCountsForReport,
|
||||
} from '@ktx/context/ingest';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
||||
import { readIngestReportSnapshotFile } from './ingest-report-file.js';
|
||||
import { createCliOperationalLogger } from './io/logger.js';
|
||||
import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
|
||||
|
|
@ -69,6 +71,7 @@ interface KtxIngestDeps {
|
|||
jobIdFactory?: () => string;
|
||||
now?: () => Date;
|
||||
createAdapters?: typeof createKtxCliLocalIngestAdapters;
|
||||
createQueryExecutor?: (project: KtxLocalProject) => KtxSqlQueryExecutorPort;
|
||||
runLocalIngest?: typeof runLocalIngest;
|
||||
runLocalMetabaseIngest?: typeof runLocalMetabaseIngest;
|
||||
readReportFile?: typeof readIngestReportSnapshotFile;
|
||||
|
|
@ -532,6 +535,9 @@ export async function runKtxIngest(
|
|||
...(args.adapter === 'historic-sql' ? { historicSqlConnectionId: args.connectionId } : {}),
|
||||
logger: operationalLogger,
|
||||
};
|
||||
const queryExecutor =
|
||||
localIngestOptions.queryExecutor ??
|
||||
(deps.createQueryExecutor ?? createKtxCliIngestQueryExecutor)(project);
|
||||
if (args.adapter === 'metabase' && args.sourceDir) {
|
||||
throw new Error('source-dir uploads are not supported for the Metabase fan-out adapter');
|
||||
}
|
||||
|
|
@ -544,6 +550,7 @@ export async function runKtxIngest(
|
|||
adapters: createAdapters(project, adapterOptions),
|
||||
metabaseConnectionId: args.connectionId,
|
||||
...localIngestOptions,
|
||||
queryExecutor,
|
||||
trigger: 'manual_resync',
|
||||
jobIdFactory: deps.jobIdFactory,
|
||||
...(progress ? { progress } : {}),
|
||||
|
|
@ -604,6 +611,7 @@ export async function runKtxIngest(
|
|||
trigger: 'manual_resync',
|
||||
jobId,
|
||||
...localIngestOptions,
|
||||
queryExecutor,
|
||||
pullConfigOptions: adapterOptions,
|
||||
...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}),
|
||||
...(memoryFlow ? { memoryFlow } : {}),
|
||||
|
|
|
|||
|
|
@ -93,6 +93,65 @@ describe('runKtxKnowledge', () => {
|
|||
expect(searchIo.stdout()).toContain('metrics-revenue');
|
||||
});
|
||||
|
||||
it('prints wiki list, search, and read as public JSON envelopes', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
expect(JSON.parse(listIo.stdout())).toMatchObject({
|
||||
kind: 'list',
|
||||
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
|
||||
meta: { command: 'wiki list' },
|
||||
});
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{ command: 'search', projectDir, query: 'paid order', userId: 'local', json: true, limit: 5 },
|
||||
searchIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(searchIo.stdout())).toMatchObject({
|
||||
kind: 'list',
|
||||
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
|
||||
meta: { command: 'wiki search' },
|
||||
});
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local', json: true }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(readIo.stdout())).toMatchObject({
|
||||
kind: 'wiki.page',
|
||||
data: {
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import {
|
|||
searchLocalKnowledgePages,
|
||||
writeLocalKnowledgePage,
|
||||
} from '@ktx/context/wiki';
|
||||
import { writeJsonResult } from './io/print-list.js';
|
||||
|
||||
export type KtxKnowledgeArgs =
|
||||
| { command: 'list'; projectDir: string; userId: string }
|
||||
| { command: 'read'; projectDir: string; key: string; userId: string }
|
||||
| { command: 'search'; projectDir: string; query: string; userId: string }
|
||||
| { command: 'list'; projectDir: string; userId: string; json?: boolean }
|
||||
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
|
||||
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number }
|
||||
| {
|
||||
command: 'write';
|
||||
projectDir: string;
|
||||
|
|
@ -61,6 +62,14 @@ export async function runKtxKnowledge(
|
|||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
if (args.command === 'list') {
|
||||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'list',
|
||||
data: { items: pages },
|
||||
meta: { command: 'wiki list' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
for (const page of pages) {
|
||||
io.stdout.write(`${page.scope}\t${page.key}\t${page.summary}\n`);
|
||||
}
|
||||
|
|
@ -71,6 +80,14 @@ export async function runKtxKnowledge(
|
|||
if (!page) {
|
||||
throw new Error(`Knowledge page "${args.key}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'wiki.page',
|
||||
data: page,
|
||||
meta: { command: 'wiki read' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
io.stdout.write(`# ${page.key}\n\n`);
|
||||
io.stdout.write(`Scope: ${page.scope}\n`);
|
||||
io.stdout.write(`Summary: ${page.summary}\n\n`);
|
||||
|
|
@ -82,7 +99,16 @@ export async function runKtxKnowledge(
|
|||
query: args.query,
|
||||
userId: args.userId,
|
||||
embeddingService: wikiSearchEmbeddingService(project, deps),
|
||||
limit: args.limit,
|
||||
});
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'list',
|
||||
data: { items: results },
|
||||
meta: { command: 'wiki search' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||
if (pages.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
doctorManagedPythonRuntime,
|
||||
installManagedPythonRuntime,
|
||||
managedPythonRuntimeLayout,
|
||||
pruneManagedPythonRuntimes,
|
||||
readManagedPythonRuntimeStatus,
|
||||
verifyRuntimeAsset,
|
||||
type ManagedPythonRuntimeExec,
|
||||
|
|
@ -471,41 +470,3 @@ describe('doctorManagedPythonRuntime', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneManagedPythonRuntimes', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-runtime-prune-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes stale version directories and keeps the current version', async () => {
|
||||
const runtimeRoot = join(tempDir, 'runtime');
|
||||
await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true });
|
||||
await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true });
|
||||
await writeFile(join(runtimeRoot, 'README.txt'), 'not a runtime directory\n');
|
||||
|
||||
const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot });
|
||||
|
||||
expect(result.removed).toEqual([join(runtimeRoot, '0.1.0')]);
|
||||
expect(result.kept).toEqual([join(runtimeRoot, '0.2.0')]);
|
||||
await expect(stat(join(runtimeRoot, '0.1.0'))).rejects.toThrow();
|
||||
expect(await readdir(runtimeRoot)).toEqual(['0.2.0', 'README.txt']);
|
||||
});
|
||||
|
||||
it('supports dry-run without deleting stale directories', async () => {
|
||||
const runtimeRoot = join(tempDir, 'runtime');
|
||||
await mkdir(join(runtimeRoot, '0.1.0'), { recursive: true });
|
||||
await mkdir(join(runtimeRoot, '0.2.0'), { recursive: true });
|
||||
|
||||
const result = await pruneManagedPythonRuntimes({ cliVersion: '0.2.0', runtimeRoot, dryRun: true });
|
||||
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(result.stale).toEqual([join(runtimeRoot, '0.1.0')]);
|
||||
expect(await readdir(runtimeRoot)).toEqual(['0.1.0', '0.2.0']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { access, appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -107,13 +107,6 @@ export interface ManagedPythonRuntimeDoctorCheck {
|
|||
fix?: string;
|
||||
}
|
||||
|
||||
export interface ManagedPythonRuntimePruneResult {
|
||||
runtimeRoot: string;
|
||||
stale: string[];
|
||||
kept: string[];
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
export const MISSING_UV_RUNTIME_INSTALL_MESSAGE =
|
||||
'uv is required to install the KTX Python runtime. KTX does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx dev runtime install --yes';
|
||||
|
||||
|
|
@ -441,36 +434,3 @@ export async function doctorManagedPythonRuntime(
|
|||
);
|
||||
return checks;
|
||||
}
|
||||
|
||||
export async function pruneManagedPythonRuntimes(options: {
|
||||
cliVersion: string;
|
||||
runtimeRoot: string;
|
||||
dryRun?: boolean;
|
||||
}): Promise<ManagedPythonRuntimePruneResult> {
|
||||
if (!(await pathExists(options.runtimeRoot))) {
|
||||
return { runtimeRoot: options.runtimeRoot, stale: [], kept: [], removed: [] };
|
||||
}
|
||||
const entries = await readdir(options.runtimeRoot);
|
||||
const stale: string[] = [];
|
||||
const kept: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const path = join(options.runtimeRoot, entry);
|
||||
const info = await stat(path);
|
||||
if (!info.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry === options.cliVersion) {
|
||||
kept.push(path);
|
||||
} else {
|
||||
stale.push(path);
|
||||
}
|
||||
}
|
||||
const removed: string[] = [];
|
||||
if (options.dryRun !== true) {
|
||||
for (const path of stale) {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
removed.push(path);
|
||||
}
|
||||
}
|
||||
return { runtimeRoot: options.runtimeRoot, stale, kept, removed };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* @jsxImportSource react */
|
||||
import type { MemoryFlowEvent, MemoryFlowReplayInput } from '@ktx/context/ingest/memory-flow';
|
||||
import { Box, Text } from 'ink';
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { buildDemoMetrics, formatCost, formatDuration } from './demo-metrics.js';
|
||||
import { formatNextStepLines } from './next-steps.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
|
@ -38,45 +38,6 @@ function isPrepopulatedDemoReplay(input: MemoryFlowReplayInput): boolean {
|
|||
return input.metadata?.origin === 'packaged' || input.metadata?.timing === 'prebuilt';
|
||||
}
|
||||
|
||||
function flowLine(width: number, frame: number, active: boolean): string {
|
||||
if (!active) return '━'.repeat(width);
|
||||
const pulse = ['░', '▒', '▓', '█', '█', '█', '▓', '▒', '░'];
|
||||
const pw = pulse.length;
|
||||
const chars: string[] = [];
|
||||
const offset = (frame * 2) % (width + pw);
|
||||
for (let i = 0; i < width; i += 1) {
|
||||
const p = i - offset + pw;
|
||||
chars.push(p >= 0 && p < pw ? (pulse[p] ?? '━') : '━');
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function brailleFlow(width: number, frame: number): string {
|
||||
// Braille unicode: U+2800 + dot bitmask
|
||||
// Dots: 1=0x01 2=0x02 3=0x04 4=0x08 5=0x10 6=0x20 7=0x40 8=0x80
|
||||
// Layout: col0=[1,2,3,7] col1=[4,5,6,8]
|
||||
const chars: string[] = [];
|
||||
for (let i = 0; i < width; i += 1) {
|
||||
const density = (i + 1) / width;
|
||||
const phase = (i * 3 + frame * 2) % 12;
|
||||
let dots = 0;
|
||||
|
||||
// Sparse diagonal streams on the left, dense on the right
|
||||
// Each "stream" is a diagonal line of dots moving rightward
|
||||
if ((phase + 0) % 4 < density * 4) dots |= 0x01; // dot 1
|
||||
if ((phase + 1) % 5 < density * 4) dots |= 0x08; // dot 4
|
||||
if ((phase + 2) % 4 < density * 3) dots |= 0x02; // dot 2
|
||||
if ((phase + 3) % 5 < density * 3) dots |= 0x10; // dot 5
|
||||
if ((phase + 4) % 4 < density * 2.5) dots |= 0x04; // dot 3
|
||||
if ((phase + 5) % 5 < density * 2.5) dots |= 0x20; // dot 6
|
||||
if ((phase + 1) % 6 < density * 2) dots |= 0x40; // dot 7
|
||||
if ((phase + 3) % 6 < density * 2) dots |= 0x80; // dot 8
|
||||
|
||||
chars.push(String.fromCharCode(0x2800 + dots));
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function progressBarOverall(
|
||||
finishedCount: number,
|
||||
activeCount: number,
|
||||
|
|
@ -104,43 +65,6 @@ function progressBarOverall(
|
|||
return finished + activeChars.join('') + '░'.repeat(queuedWidth);
|
||||
}
|
||||
|
||||
function sparkleWipe(width: number, frame: number, row: number): string {
|
||||
const chars: string[] = [];
|
||||
const sweepPos = (frame * 2 + row * 6) % (width + 8);
|
||||
const sparkles = ['✨', '✦', '✧', '·'];
|
||||
for (let i = 0; i < width; i += 1) {
|
||||
const dist = i - sweepPos;
|
||||
if (dist < -6) {
|
||||
const t = (i * 11 + row * 5 + frame * 3) % 10;
|
||||
chars.push(t === 0 ? sparkles[0]! : t === 3 ? sparkles[1]! : t === 7 ? sparkles[2]! : ' ');
|
||||
} else if (dist < -3) {
|
||||
const t = (i + frame) % 3;
|
||||
chars.push(t === 0 ? sparkles[1]! : t === 1 ? sparkles[2]! : sparkles[3]!);
|
||||
} else if (dist <= 0) {
|
||||
const gradient = ['░', '▒', '▓', '█'];
|
||||
chars.push(gradient[Math.min(3, dist + 3)] ?? '█');
|
||||
} else if (dist <= 2) {
|
||||
chars.push(dist === 1 ? '▓' : '▒');
|
||||
} else {
|
||||
const noise = (i * 31 + row * 17 + frame * 3) % 5;
|
||||
const messy = ['░', '▒', '▓', '▒', '░'];
|
||||
chars.push(messy[noise] ?? '▒');
|
||||
}
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function activityWave(width: number, frame: number, offset: number): string {
|
||||
const heights = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
const chars: string[] = [];
|
||||
for (let i = 0; i < width; i += 1) {
|
||||
const wave = Math.sin(((i * 2 + frame + offset * 5) * Math.PI) / 6);
|
||||
const idx = Math.round(((wave + 1) / 2) * (heights.length - 1));
|
||||
chars.push(heights[idx] ?? '▁');
|
||||
}
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
function topicName(key: string): string {
|
||||
return (key.split('/').pop()?.replace(/\.md$/, '') ?? key).replace(/[_-]/g, ' ');
|
||||
}
|
||||
|
|
@ -155,18 +79,9 @@ function humanizeInsight(key: string, target: 'sl' | 'wiki', summary: string | u
|
|||
return target === 'sl' ? `Query definition: ${name}` : `Knowledge page: ${name}`;
|
||||
}
|
||||
|
||||
const ADAPTER_PREFIXES = ['live_database_', 'metabase_', 'looker_', 'lookml_', 'metricflow_', 'notion_', 'historic_sql_', 'dbt_descriptions_'];
|
||||
const INTERNAL_DEMO_CONNECTION_ID = 'orbit_demo';
|
||||
const PUBLIC_DEMO_SOURCE_LABEL = 'Orbit Demo';
|
||||
|
||||
function humanizeUnitKey(unitKey: string): string {
|
||||
let key = unitKey.replace(/-/g, '_');
|
||||
for (const prefix of ADAPTER_PREFIXES) {
|
||||
if (key.startsWith(prefix)) { key = key.slice(prefix.length); break; }
|
||||
}
|
||||
return key.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
interface SourceInfo {
|
||||
type: string;
|
||||
name: string;
|
||||
|
|
@ -224,13 +139,6 @@ function sourceDescription(input: MemoryFlowReplayInput): SourceInfo {
|
|||
return { type: info.type, name: conn, sourceCount: count, itemNounPlural: info.plural, readingVerb: info.verb, ingestDescription: info.description };
|
||||
}
|
||||
|
||||
function activeWorkUnit(
|
||||
input: MemoryFlowReplayInput,
|
||||
): { unitKey: string; stepIndex: number; stepBudget: number } | null {
|
||||
const units = activeWorkUnits(input);
|
||||
return units.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function activeWorkUnits(
|
||||
input: MemoryFlowReplayInput,
|
||||
): Array<{ unitKey: string; stepIndex: number; stepBudget: number }> {
|
||||
|
|
@ -299,22 +207,6 @@ function finishedUnits(input: MemoryFlowReplayInput): Array<{ unitKey: string; a
|
|||
return units;
|
||||
}
|
||||
|
||||
function artifactCounts(input: MemoryFlowReplayInput): { sl: number; wiki: number } {
|
||||
let sl = 0;
|
||||
let wiki = 0;
|
||||
for (const e of input.events) {
|
||||
if (e.type === 'candidate_action') {
|
||||
if (e.target === 'sl') sl++;
|
||||
else wiki++;
|
||||
}
|
||||
}
|
||||
return { sl, wiki };
|
||||
}
|
||||
|
||||
function pad(str: string, width: number): string {
|
||||
return str.length >= width ? str : str + ' '.repeat(width - str.length);
|
||||
}
|
||||
|
||||
const KTX_LOGO_SMALL = [
|
||||
'██╗ ██╗████████╗██╗ ██╗',
|
||||
'██║ ██╔╝╚══██╔══╝╚██╗██╔╝',
|
||||
|
|
@ -344,12 +236,7 @@ export function Hud(props: {
|
|||
width: number;
|
||||
now?: () => number;
|
||||
}): ReactNode {
|
||||
const isRunning = props.input.status === 'running';
|
||||
const isDone = props.input.status === 'done';
|
||||
const isFlowing = isRunning && hasWorkStarted(props.input);
|
||||
|
||||
const src = sourceDescription(props.input);
|
||||
const counts = artifactCounts(props.input);
|
||||
const metrics = buildDemoMetrics(props.input, props.now ? { now: props.now } : {});
|
||||
const workStarted = hasWorkStarted(props.input);
|
||||
|
||||
|
|
@ -358,11 +245,6 @@ export function Hud(props: {
|
|||
|
||||
const innerWidth = Math.max(60, props.width - 6);
|
||||
|
||||
const actives = activeWorkUnits(props.input);
|
||||
const reconEvent = props.input.events.find((e) => e.type === 'reconciliation_finished');
|
||||
const allAnalyzed = isFlowing && actives.length === 0;
|
||||
const isReconciling = allAnalyzed && !reconEvent && !isDone;
|
||||
|
||||
const hLine = '─'.repeat(innerWidth);
|
||||
|
||||
const elapsed = formatDuration(metrics.elapsedMs);
|
||||
|
|
@ -429,7 +311,6 @@ export function ActivityFeed(props: {
|
|||
|
||||
const workStarted = hasWorkStarted(props.input);
|
||||
const totalChunks = planEvent?.chunkCount ?? 0;
|
||||
const finishedWithArtifacts = finished.filter((u) => u.artifactCount > 0);
|
||||
const finishedAreas = totalChunks > 0 ? Math.min(finished.length, totalChunks) : finished.length;
|
||||
const allWorkDone = workStarted && actives.length === 0 && queued.length === 0;
|
||||
const isReconciling = allWorkDone && !reconEvent && !isDone && !isError;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
startLiveMemoryFlowTui,
|
||||
type KtxMemoryFlowTuiIo,
|
||||
type MemoryFlowInkInstance,
|
||||
type MemoryFlowInkRenderOptions,
|
||||
} from './memory-flow-tui.js';
|
||||
|
||||
function replayInput(): MemoryFlowReplayInput {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/* @jsxImportSource react */
|
||||
import {
|
||||
buildMemoryFlowViewModel,
|
||||
buildMemoryFlowVisualModel,
|
||||
createInitialMemoryFlowInteractionState,
|
||||
findMemoryFlowSearchMatches,
|
||||
type MemoryFlowColumnId,
|
||||
|
|
@ -14,8 +13,7 @@ import {
|
|||
selectedMemoryFlowDetails,
|
||||
} from '@ktx/context/ingest';
|
||||
import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink';
|
||||
import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { buildDemoMetrics } from './demo-metrics.js';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityFeed,
|
||||
Hud,
|
||||
|
|
@ -201,14 +199,6 @@ function stageLabel(columnId: MemoryFlowColumnId): string {
|
|||
return STAGE_LABELS[columnId];
|
||||
}
|
||||
|
||||
function statusLabel(status: string): 'OK' | 'RUN' | 'WARN' | 'FAIL' | 'WAIT' {
|
||||
if (status === 'complete') return 'OK';
|
||||
if (status === 'active') return 'RUN';
|
||||
if (status === 'warning') return 'WARN';
|
||||
if (status === 'failed') return 'FAIL';
|
||||
return 'WAIT';
|
||||
}
|
||||
|
||||
function filterLabel(filter: MemoryFlowInteractionState['filter']): string {
|
||||
return filter === 'failed_or_flagged' ? 'issues' : 'all';
|
||||
}
|
||||
|
|
@ -325,7 +315,6 @@ export function MemoryFlowTuiApp(props: MemoryFlowTuiAppProps): ReactNode {
|
|||
const view = useMemo(() => buildMemoryFlowViewModel(pacedInput), [pacedInput]);
|
||||
const [state, setState] = useState<MemoryFlowInteractionState>(() => createInitialMemoryFlowInteractionState(view));
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [thoughtFrame, setThoughtFrame] = useState(0);
|
||||
const [completionFrame, setCompletionFrame] = useState(0);
|
||||
const [holdComplete, setHoldComplete] = useState(false);
|
||||
const [userHasNavigated, setUserHasNavigated] = useState(false);
|
||||
|
|
@ -346,7 +335,6 @@ export function MemoryFlowTuiApp(props: MemoryFlowTuiAppProps): ReactNode {
|
|||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((current) => current + 1);
|
||||
setThoughtFrame((current) => current + 1);
|
||||
}, props.frameMs ?? DEFAULT_TUI_TIMING.frameMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [props.frameMs]);
|
||||
|
|
@ -354,7 +342,6 @@ export function MemoryFlowTuiApp(props: MemoryFlowTuiAppProps): ReactNode {
|
|||
useEffect(() => {
|
||||
if (lastEventCountRef.current !== pacedInput.events.length) {
|
||||
lastEventCountRef.current = pacedInput.events.length;
|
||||
setThoughtFrame(0);
|
||||
}
|
||||
}, [pacedInput.events.length]);
|
||||
|
||||
|
|
@ -409,10 +396,6 @@ export function MemoryFlowTuiApp(props: MemoryFlowTuiAppProps): ReactNode {
|
|||
});
|
||||
|
||||
const isComplete = pacedInput.status === 'done' || pacedInput.status === 'error';
|
||||
const completionMetrics = useMemo(
|
||||
() => buildDemoMetrics(pacedInput, pacedNow ? { now: pacedNow } : {}),
|
||||
[pacedInput, pacedNow],
|
||||
);
|
||||
|
||||
const termWidth = props.terminalWidth ?? 80;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,8 @@ describe('KTX demo next steps', () => {
|
|||
it('uses supported final public commands', () => {
|
||||
expect(KTX_NEXT_STEP_COMMANDS).toEqual([
|
||||
{
|
||||
command: 'ktx agent context --json',
|
||||
description: 'Verify the project context your agent can read',
|
||||
},
|
||||
{
|
||||
command: 'ktx agent tools --json',
|
||||
description: 'List direct CLI tools available to agents',
|
||||
command: 'ktx status --json',
|
||||
description: 'Verify project setup and context readiness',
|
||||
},
|
||||
{
|
||||
command: 'ktx sl list',
|
||||
|
|
@ -46,8 +42,8 @@ describe('KTX demo next steps', () => {
|
|||
it('uses only the direct CLI route for agent verification', () => {
|
||||
const commands = KTX_NEXT_STEP_COMMANDS.map((step) => step.command);
|
||||
|
||||
expect(commands).toContain('ktx agent context --json');
|
||||
expect(commands).toContain('ktx agent tools --json');
|
||||
expect(commands).not.toContain('ktx agent context --json');
|
||||
expect(commands).toContain('ktx status --json');
|
||||
expect(commands).not.toContain('ktx serve --mcp stdio --user-id local');
|
||||
});
|
||||
|
||||
|
|
@ -64,8 +60,8 @@ describe('KTX demo next steps', () => {
|
|||
it('does not advertise removed Commander migration commands', () => {
|
||||
const rendered = formatNextStepLines().join('\n');
|
||||
|
||||
expect(rendered).toContain('ktx agent tools --json');
|
||||
expect(rendered).toContain('ktx agent context --json');
|
||||
expect(rendered).toContain('ktx status --json');
|
||||
expect(rendered).not.toContain('ktx agent');
|
||||
expect(rendered).toContain('ktx sl list');
|
||||
expect(rendered).toContain('ktx wiki list');
|
||||
|
||||
|
|
@ -109,7 +105,8 @@ describe('KTX demo next steps', () => {
|
|||
}).join('\n');
|
||||
|
||||
expect(rendered).toContain('KTX context is ready for agents.');
|
||||
expect(rendered).toContain('ktx agent context --json');
|
||||
expect(rendered).toContain('ktx status --json');
|
||||
expect(rendered).not.toContain('ktx agent');
|
||||
expect(rendered).not.toContain('ktx serve --mcp stdio --user-id local');
|
||||
expect(rendered).not.toContain('Build KTX context next.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,12 +11,8 @@ export const KTX_CONTEXT_BUILD_COMMANDS = [
|
|||
|
||||
export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
|
||||
{
|
||||
command: 'ktx agent context --json',
|
||||
description: 'Verify the project context your agent can read',
|
||||
},
|
||||
{
|
||||
command: 'ktx agent tools --json',
|
||||
description: 'List direct CLI tools available to agents',
|
||||
command: 'ktx status --json',
|
||||
description: 'Verify project setup and context readiness',
|
||||
},
|
||||
{
|
||||
command: 'ktx sl list',
|
||||
|
|
|
|||
|
|
@ -35,8 +35,7 @@ describe('project directory defaults', () => {
|
|||
const ingest = vi.fn(async () => 0);
|
||||
const scan = vi.fn(async () => 0);
|
||||
const setup = vi.fn(async () => 0);
|
||||
const agent = vi.fn(async () => 0);
|
||||
const deps: KtxCliDeps = { agent, connection, doctor, ingest, scan, setup };
|
||||
const deps: KtxCliDeps = { connection, doctor, ingest, scan, setup };
|
||||
|
||||
const cases: Array<{
|
||||
argv: string[];
|
||||
|
|
@ -74,12 +73,6 @@ describe('project directory defaults', () => {
|
|||
expected: { command: 'run', projectDir: '/tmp/ktx-env-project', connectionId: 'warehouse' },
|
||||
expectedStderr: 'Project: /tmp/ktx-env-project\n',
|
||||
},
|
||||
{
|
||||
argv: ['agent', 'tools', '--json'],
|
||||
spy: agent,
|
||||
expected: { command: 'tools', projectDir: '/tmp/ktx-env-project' },
|
||||
expectedStderr: '',
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { resolve } from 'node:path';
|
||||
|
||||
export function resolveProjectDir(projectDir?: string, fallback = '.'): string {
|
||||
return resolve(projectDir ?? fallback);
|
||||
}
|
||||
|
|
@ -6,9 +6,9 @@ import { profileMark } from './startup-profile.js';
|
|||
|
||||
profileMark('module:public-ingest');
|
||||
|
||||
export type KtxPublicIngestStepName = 'scan' | 'source-ingest' | 'enrich' | 'memory-update';
|
||||
export type KtxPublicIngestStepStatus = 'done' | 'skipped' | 'failed' | 'not-run';
|
||||
export type KtxPublicIngestInputMode = 'auto' | 'disabled';
|
||||
type KtxPublicIngestStepName = 'scan' | 'source-ingest' | 'enrich' | 'memory-update';
|
||||
type KtxPublicIngestStepStatus = 'done' | 'skipped' | 'failed' | 'not-run';
|
||||
type KtxPublicIngestInputMode = 'auto' | 'disabled';
|
||||
|
||||
export type KtxPublicIngestArgs =
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import type {
|
||||
ManagedPythonRuntimeDoctorCheck,
|
||||
ManagedPythonRuntimeInstallResult,
|
||||
ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
|
|
@ -256,7 +257,7 @@ describe('runKtxRuntime', () => {
|
|||
expect(io.stderr()).toContain('process scan: ps failed');
|
||||
});
|
||||
|
||||
it('prints runtime status as JSON', async () => {
|
||||
it('prints runtime status and doctor checks as JSON with doctor-style exit status', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
readStatus: vi.fn(async (): Promise<ManagedPythonRuntimeStatus> => ({
|
||||
|
|
@ -278,38 +279,41 @@ describe('runKtxRuntime', () => {
|
|||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
})),
|
||||
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
|
||||
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
|
||||
{ id: 'asset', label: 'Bundled Python wheel', status: 'pass', detail: '/assets/python/runtime.whl' },
|
||||
{
|
||||
id: 'runtime',
|
||||
label: 'Managed Python runtime',
|
||||
status: 'fail',
|
||||
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
|
||||
fix: 'Run: ktx dev runtime install --yes',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: true }, io.io, deps)).resolves.toBe(0);
|
||||
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: true }, io.io, deps)).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({
|
||||
kind: 'missing',
|
||||
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
|
||||
layout: { runtimeRoot: '/runtime' },
|
||||
checks: [
|
||||
{ id: 'uv', status: 'pass' },
|
||||
{ id: 'asset', status: 'pass' },
|
||||
{ id: 'runtime', status: 'fail' },
|
||||
],
|
||||
});
|
||||
expect(deps.readStatus).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
|
||||
expect(deps.doctorRuntime).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
|
||||
});
|
||||
|
||||
it('requires --yes before pruning stale runtime directories', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
pruneRuntime: vi.fn(async () => {
|
||||
throw new Error('should not prune without --yes');
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: false, yes: false }, io.io, deps))
|
||||
.resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('Refusing to prune without --yes');
|
||||
expect(deps.pruneRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints stale directories during prune dry-run', async () => {
|
||||
it('prints runtime status and doctor checks in plain output', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
readStatus: vi.fn(async (): Promise<ManagedPythonRuntimeStatus> => ({
|
||||
kind: 'missing',
|
||||
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
|
||||
kind: 'ready',
|
||||
detail: 'Runtime ready at /runtime/0.2.0',
|
||||
layout: {
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: '/runtime',
|
||||
|
|
@ -325,19 +329,43 @@ describe('runKtxRuntime', () => {
|
|||
daemonStdoutPath: '/runtime/0.2.0/daemon.stdout.log',
|
||||
daemonStderrPath: '/runtime/0.2.0/daemon.stderr.log',
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
cliVersion: '0.2.0',
|
||||
installedAt: '2026-05-11T00:00:00.000Z',
|
||||
asset: {
|
||||
schemaVersion: 1,
|
||||
distributionName: 'kaelio-ktx',
|
||||
normalizedName: 'kaelio_ktx',
|
||||
version: '0.1.0',
|
||||
wheel: {
|
||||
file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
|
||||
sha256: 'a'.repeat(64),
|
||||
bytes: 10,
|
||||
},
|
||||
},
|
||||
features: ['core'],
|
||||
python: {
|
||||
executable: '/runtime/0.2.0/.venv/bin/python',
|
||||
daemonExecutable: '/runtime/0.2.0/.venv/bin/ktx-daemon',
|
||||
},
|
||||
installLog: '/runtime/0.2.0/install.log',
|
||||
},
|
||||
})),
|
||||
pruneRuntime: vi.fn(async () => ({
|
||||
runtimeRoot: '/runtime',
|
||||
stale: ['/runtime/0.1.0'],
|
||||
kept: ['/runtime/0.2.0'],
|
||||
removed: [],
|
||||
})),
|
||||
doctorRuntime: vi.fn(async (): Promise<ManagedPythonRuntimeDoctorCheck[]> => [
|
||||
{ id: 'uv', label: 'uv', status: 'pass', detail: 'uv 0.9.5' },
|
||||
{ id: 'asset', label: 'Bundled Python wheel', status: 'pass', detail: '/assets/python/runtime.whl' },
|
||||
{ id: 'runtime', label: 'Managed Python runtime', status: 'pass', detail: 'Runtime ready at /runtime/0.2.0' },
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'prune', cliVersion: '0.2.0', dryRun: true, yes: false }, io.io, deps))
|
||||
.resolves.toBe(0);
|
||||
await expect(runKtxRuntime({ command: 'status', cliVersion: '0.2.0', json: false }, io.io, deps)).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Stale KTX Python runtimes');
|
||||
expect(io.stdout()).toContain('/runtime/0.1.0');
|
||||
expect(io.stdout()).toContain('KTX Python runtime');
|
||||
expect(io.stdout()).toContain('status: ready');
|
||||
expect(io.stdout()).toContain('KTX Python runtime checks');
|
||||
expect(io.stdout()).toContain('PASS uv: uv 0.9.5');
|
||||
expect(io.stdout()).toContain('PASS Managed Python runtime: Runtime ready at /runtime/0.2.0');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ import {
|
|||
type ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
import {
|
||||
doctorManagedPythonRuntime,
|
||||
installManagedPythonRuntime,
|
||||
pruneManagedPythonRuntimes,
|
||||
readManagedPythonRuntimeStatus,
|
||||
type KtxRuntimeFeature,
|
||||
type ManagedPythonRuntimeDoctorCheck,
|
||||
type ManagedPythonRuntimeInstallOptions,
|
||||
type ManagedPythonRuntimeInstallResult,
|
||||
type ManagedPythonRuntimeLayoutOptions,
|
||||
type ManagedPythonRuntimePruneResult,
|
||||
type ManagedPythonRuntimeStatus,
|
||||
} from './managed-python-runtime.js';
|
||||
|
||||
|
|
@ -23,8 +23,7 @@ export type KtxRuntimeArgs =
|
|||
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'stop'; cliVersion: string; all: boolean }
|
||||
| { command: 'status'; cliVersion: string; json: boolean }
|
||||
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
|
||||
| { command: 'status'; cliVersion: string; json: boolean };
|
||||
|
||||
export interface KtxRuntimeDeps {
|
||||
installRuntime?: (options: ManagedPythonRuntimeInstallOptions) => Promise<ManagedPythonRuntimeInstallResult>;
|
||||
|
|
@ -36,11 +35,7 @@ export interface KtxRuntimeDeps {
|
|||
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
|
||||
stopAllDaemons?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopAllResult>;
|
||||
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
|
||||
pruneRuntime?: (options: {
|
||||
cliVersion: string;
|
||||
runtimeRoot: string;
|
||||
dryRun?: boolean;
|
||||
}) => Promise<ManagedPythonRuntimePruneResult>;
|
||||
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
|
||||
}
|
||||
|
||||
function writeJson(io: KtxCliIo, value: unknown): void {
|
||||
|
|
@ -145,17 +140,20 @@ function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
|
|||
}
|
||||
}
|
||||
|
||||
function writePrune(io: KtxCliIo, result: ManagedPythonRuntimePruneResult, dryRun: boolean): void {
|
||||
if (result.stale.length === 0) {
|
||||
io.stdout.write(`No stale KTX Python runtimes found under ${result.runtimeRoot}\n`);
|
||||
return;
|
||||
}
|
||||
io.stdout.write(dryRun ? 'Stale KTX Python runtimes\n' : 'Removed stale KTX Python runtimes\n');
|
||||
for (const path of dryRun ? result.stale : result.removed) {
|
||||
io.stdout.write(`${path}\n`);
|
||||
function writeRuntimeChecks(io: KtxCliIo, checks: ManagedPythonRuntimeDoctorCheck[]): void {
|
||||
io.stdout.write('KTX Python runtime checks\n');
|
||||
for (const check of checks) {
|
||||
io.stdout.write(`${check.status.toUpperCase()} ${check.label}: ${check.detail}\n`);
|
||||
if (check.fix) {
|
||||
io.stdout.write(` Fix: ${check.fix}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasRuntimeCheckFailures(checks: ManagedPythonRuntimeDoctorCheck[]): boolean {
|
||||
return checks.some((check) => check.status === 'fail');
|
||||
}
|
||||
|
||||
export async function runKtxRuntime(
|
||||
args: KtxRuntimeArgs,
|
||||
io: KtxCliIo = process,
|
||||
|
|
@ -196,27 +194,19 @@ export async function runKtxRuntime(
|
|||
}
|
||||
if (args.command === 'status') {
|
||||
const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;
|
||||
const doctorRuntime = deps.doctorRuntime ?? doctorManagedPythonRuntime;
|
||||
const status = await readStatus({ cliVersion: args.cliVersion });
|
||||
const checks = await doctorRuntime({ cliVersion: args.cliVersion });
|
||||
if (args.json) {
|
||||
writeJson(io, status);
|
||||
writeJson(io, { ...status, checks });
|
||||
} else {
|
||||
writeStatus(io, status);
|
||||
writeRuntimeChecks(io, checks);
|
||||
}
|
||||
return 0;
|
||||
return hasRuntimeCheckFailures(checks) ? 1 : 0;
|
||||
}
|
||||
if (!args.dryRun && !args.yes) {
|
||||
io.stderr.write('Refusing to prune without --yes. Preview with: ktx dev runtime prune --dry-run\n');
|
||||
return 1;
|
||||
}
|
||||
const status = await (deps.readStatus ?? readManagedPythonRuntimeStatus)({ cliVersion: args.cliVersion });
|
||||
const pruneRuntime = deps.pruneRuntime ?? pruneManagedPythonRuntimes;
|
||||
const result = await pruneRuntime({
|
||||
cliVersion: args.cliVersion,
|
||||
runtimeRoot: status.layout.runtimeRoot,
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
writePrune(io, result, args.dryRun);
|
||||
return 0;
|
||||
const _exhaustive: never = args;
|
||||
return _exhaustive;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -84,7 +84,10 @@ describe('setup agents', () => {
|
|||
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
|
||||
expect(skill).toContain(`--project-dir ${tempDir}`);
|
||||
expect(skill).toContain('must not print secrets');
|
||||
expect(skill).toContain('agent sql execute');
|
||||
expect(skill).toContain('status --json');
|
||||
expect(skill).toContain('sl list --json');
|
||||
expect(skill).not.toContain('agent ');
|
||||
expect(skill).not.toContain('sql execute');
|
||||
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
|
||||
version: 1,
|
||||
projectDir: tempDir,
|
||||
|
|
@ -115,8 +118,9 @@ describe('setup agents', () => {
|
|||
|
||||
const skill = await readFile(join(tempDir, '.agents/skills/ktx/SKILL.md'), 'utf-8');
|
||||
expect(skill).not.toContain('`ktx agent');
|
||||
expect(skill).toContain('agent context --json');
|
||||
expect(skill).toContain('agent sql execute');
|
||||
expect(skill).toContain('status --json');
|
||||
expect(skill).toContain('sl query');
|
||||
expect(skill).not.toContain('sql execute');
|
||||
});
|
||||
|
||||
it('removes only manifest-listed files', async () => {
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
return [
|
||||
'---',
|
||||
'name: ktx',
|
||||
'description: Use local KTX semantic context, wiki knowledge, and safe SQL execution for this project.',
|
||||
'description: Use local KTX semantic context and wiki knowledge for this project.',
|
||||
'---',
|
||||
'',
|
||||
'# KTX Local Context',
|
||||
|
|
@ -136,11 +136,11 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
'',
|
||||
'Available commands:',
|
||||
'',
|
||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'context', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'list', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'sl', 'read', '<sourceName>', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['status', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs, '--query', '<text>'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'read', '<sourceName>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, [
|
||||
'agent',
|
||||
'sl',
|
||||
'query',
|
||||
...projectDirArgs,
|
||||
|
|
@ -152,29 +152,17 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
'--max-rows',
|
||||
'100',
|
||||
])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'search', '<query>', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['agent', 'wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, [
|
||||
'agent',
|
||||
'sql',
|
||||
'execute',
|
||||
...projectDirArgs,
|
||||
'--connection-id',
|
||||
'<id>',
|
||||
'--sql-file',
|
||||
'<path>',
|
||||
'--max-rows',
|
||||
'100',
|
||||
])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...projectDirArgs, '--limit', '10'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
|
||||
'',
|
||||
'SQL execution is read-only, requires an explicit row limit, and should use the smallest useful limit.',
|
||||
'Use semantic-layer queries before direct database access. Do not print secrets or credential references.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ruleInstructionContent(input: { projectDir: string }): string {
|
||||
return [
|
||||
`Use the \`ktx\` CLI to query local semantic context, wiki knowledge, and execute safe SQL for this project (\`--project-dir ${input.projectDir}\`).`,
|
||||
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project (\`--project-dir ${input.projectDir}\`).`,
|
||||
'',
|
||||
'Use when the user asks about data schemas, metrics, dimensions, database structure, or wants to run SQL queries.',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -472,16 +472,6 @@ async function markContextComplete(projectDir: string): Promise<void> {
|
|||
await markKtxSetupStateStepComplete(projectDir, 'context');
|
||||
}
|
||||
|
||||
function writeBuildHeader(projectDir: string, runId: string, io: KtxCliIo): void {
|
||||
const commands = contextBuildCommands(projectDir, runId);
|
||||
io.stdout.write('\nKTX context build\n');
|
||||
io.stdout.write(`Run: ${runId}\n`);
|
||||
io.stdout.write(`Project: ${resolve(projectDir)}\n\n`);
|
||||
io.stdout.write('Detach: press d to leave this running.\n');
|
||||
io.stdout.write(`Resume: ${commands.watch}\n`);
|
||||
io.stdout.write(`Status: ${commands.status}\n\n`);
|
||||
}
|
||||
|
||||
function writeMissingCapabilities(missing: string[], io: KtxCliIo): void {
|
||||
io.stderr.write('KTX cannot build agent-ready context yet.\n\n');
|
||||
io.stderr.write('Missing:\n');
|
||||
|
|
|
|||
|
|
@ -84,6 +84,71 @@ describe('runKtxSl', () => {
|
|||
expect(listIo.stdout()).toContain('warehouse\torders\tcolumns=1\tmeasures=0\tjoins=0');
|
||||
});
|
||||
|
||||
it('prints semantic-layer reads and searched lists as public JSON envelopes', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
yaml: [
|
||||
'name: orders',
|
||||
'table: public.orders',
|
||||
'description: Paid order facts',
|
||||
'grain: [order_id]',
|
||||
'columns:',
|
||||
' - name: order_id',
|
||||
' type: string',
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{ command: 'read', projectDir, connectionId: 'warehouse', sourceName: 'orders', json: true },
|
||||
readIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(readIo.stdout())).toMatchObject({
|
||||
kind: 'sl.source',
|
||||
data: {
|
||||
connectionId: 'warehouse',
|
||||
name: 'orders',
|
||||
yaml: expect.stringContaining('name: orders'),
|
||||
},
|
||||
});
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{ command: 'list', projectDir, connectionId: 'warehouse', query: 'paid', json: true },
|
||||
listIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(listIo.stdout())).toMatchObject({
|
||||
kind: 'list',
|
||||
data: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
connectionId: 'warehouse',
|
||||
name: 'orders',
|
||||
score: expect.any(Number),
|
||||
matchReasons: expect.arrayContaining(['token']),
|
||||
}),
|
||||
],
|
||||
},
|
||||
meta: { command: 'sl list' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails validation when a table-backed source declares columns absent from a matching warehouse manifest', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
|
@ -191,6 +256,73 @@ joins: []
|
|||
expect(stderr.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs sl query from a JSON query file', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
project.config.connections.warehouse = { driver: 'postgres', readonly: true };
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/orders.yaml',
|
||||
`name: orders
|
||||
table: public.orders
|
||||
grain: [id]
|
||||
columns:
|
||||
- name: id
|
||||
type: number
|
||||
measures:
|
||||
- name: order_count
|
||||
expr: count(*)
|
||||
joins: []
|
||||
`,
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'Add orders source',
|
||||
);
|
||||
const queryFile = join(tempDir, 'query.json');
|
||||
await writeFile(queryFile, '{"measures":["orders.order_count"],"dimensions":[]}', 'utf-8');
|
||||
|
||||
const stdout = { write: vi.fn() };
|
||||
const stderr = { write: vi.fn() };
|
||||
const query = vi.fn(async () => ({
|
||||
sql: 'select count(*) as order_count from public.orders',
|
||||
dialect: 'postgres',
|
||||
columns: [{ name: 'orders.order_count' }],
|
||||
plan: {},
|
||||
}));
|
||||
const createSemanticLayerCompute = vi.fn(() => ({
|
||||
query,
|
||||
validateSources: vi.fn(),
|
||||
generateSources: vi.fn(),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{
|
||||
command: 'query',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
queryFile,
|
||||
format: 'json',
|
||||
execute: false,
|
||||
cliVersion: '0.2.0',
|
||||
runtimeInstallPolicy: 'auto',
|
||||
},
|
||||
{ stdout, stderr },
|
||||
{ createSemanticLayerCompute },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: { measures: ['orders.order_count'], dimensions: [] },
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(String(stdout.write.mock.calls[0][0]))).toMatchObject({
|
||||
sql: 'select count(*) as order_count from public.orders',
|
||||
plan: { execution: { mode: 'compile_only' } },
|
||||
});
|
||||
expect(stderr.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates default sl query compute through the managed runtime helper', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { createDefaultLocalQueryExecutor, type KtxSqlQueryExecutorPort } from '@ktx/context/connections';
|
||||
import {
|
||||
createLocalKtxEmbeddingProviderFromConfig,
|
||||
KtxIngestEmbeddingPortAdapter,
|
||||
type KtxEmbeddingPort,
|
||||
} from '@ktx/context';
|
||||
import type { KtxSemanticLayerComputePort } from '@ktx/context/daemon';
|
||||
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import {
|
||||
compileLocalSlQuery,
|
||||
listLocalSlSources,
|
||||
readLocalSlSource,
|
||||
searchLocalSlSources,
|
||||
validateLocalSlSource,
|
||||
writeLocalSlSource,
|
||||
type SemanticLayerQueryInput,
|
||||
} from '@ktx/context/sl';
|
||||
import { writeJsonResult } from './io/print-list.js';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
|
|
@ -20,15 +28,16 @@ profileMark('module:sl');
|
|||
type SlQueryFormat = 'json' | 'sql';
|
||||
|
||||
export type KtxSlArgs =
|
||||
| { command: 'list'; projectDir: string; connectionId?: string; output?: string; json?: boolean }
|
||||
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string }
|
||||
| { command: 'list'; projectDir: string; connectionId?: string; query?: string; output?: string; json?: boolean }
|
||||
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string; json?: boolean }
|
||||
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
|
||||
| { command: 'write'; projectDir: string; connectionId: string; sourceName: string; yaml: string }
|
||||
| {
|
||||
command: 'query';
|
||||
projectDir: string;
|
||||
connectionId?: string;
|
||||
query: SemanticLayerQueryInput;
|
||||
query?: SemanticLayerQueryInput;
|
||||
queryFile?: string;
|
||||
format: SlQueryFormat;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
|
|
@ -43,6 +52,8 @@ interface KtxSlIo {
|
|||
|
||||
interface KtxSlDeps {
|
||||
loadProject?: typeof loadKtxProject;
|
||||
embeddingService?: KtxEmbeddingPort | null;
|
||||
createEmbeddingProvider?: typeof createLocalKtxEmbeddingProviderFromConfig;
|
||||
createSemanticLayerCompute?: () => KtxSemanticLayerComputePort;
|
||||
createManagedSemanticLayerCompute?: (options: {
|
||||
cliVersion: string;
|
||||
|
|
@ -52,11 +63,35 @@ interface KtxSlDeps {
|
|||
createQueryExecutor?: () => KtxSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
function slSearchEmbeddingService(project: KtxLocalProject, deps: KtxSlDeps): KtxEmbeddingPort | null {
|
||||
if ('embeddingService' in deps) {
|
||||
return deps.embeddingService ?? null;
|
||||
}
|
||||
const provider = (deps.createEmbeddingProvider ?? createLocalKtxEmbeddingProviderFromConfig)(
|
||||
project.config.ingest.embeddings,
|
||||
);
|
||||
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
|
||||
}
|
||||
|
||||
async function readSlQueryFile(path: string): Promise<SemanticLayerQueryInput> {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${path} must contain a JSON object.`);
|
||||
}
|
||||
return parsed as SemanticLayerQueryInput;
|
||||
}
|
||||
|
||||
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise<number> {
|
||||
try {
|
||||
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
|
||||
if (args.command === 'list') {
|
||||
const sources = await listLocalSlSources(project, { connectionId: args.connectionId });
|
||||
const sources = args.query
|
||||
? await searchLocalSlSources(project, {
|
||||
connectionId: args.connectionId,
|
||||
query: args.query,
|
||||
embeddingService: slSearchEmbeddingService(project, deps),
|
||||
})
|
||||
: await listLocalSlSources(project, { connectionId: args.connectionId });
|
||||
const { resolveOutputMode } = await import('./io/mode.js');
|
||||
const { printList } = await import('./io/print-list.js');
|
||||
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
|
||||
|
|
@ -86,6 +121,14 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
if (!source) {
|
||||
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'sl.source',
|
||||
data: source,
|
||||
meta: { command: 'sl read' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
io.stdout.write(source.yaml);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -108,6 +151,10 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
return 0;
|
||||
}
|
||||
if (args.command === 'query') {
|
||||
const query = args.query ?? (args.queryFile ? await readSlQueryFile(args.queryFile) : undefined);
|
||||
if (!query) {
|
||||
throw new Error('sl query requires query input from --query-file or at least one --measure');
|
||||
}
|
||||
const compute = deps.createSemanticLayerCompute
|
||||
? deps.createSemanticLayerCompute()
|
||||
: await (deps.createManagedSemanticLayerCompute ?? createManagedPythonSemanticLayerComputePort)({
|
||||
|
|
@ -118,7 +165,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
|
||||
const result = await compileLocalSlQuery(project as KtxLocalProject, {
|
||||
connectionId: args.connectionId,
|
||||
query: args.query,
|
||||
query,
|
||||
compute,
|
||||
execute: args.execute,
|
||||
maxRows: args.maxRows,
|
||||
|
|
|
|||
|
|
@ -126,10 +126,6 @@ async function writeSqliteScanConfig(projectDir: string, dbPath: string, enrich
|
|||
);
|
||||
}
|
||||
|
||||
function parseJsonOutput<T>(stdout: string): T {
|
||||
return JSON.parse(stdout) as T;
|
||||
}
|
||||
|
||||
function expectProjectStderr(result: CliResult, projectDir: string): void {
|
||||
expect(result).toMatchObject({ code: 0, stderr: `Project: ${projectDir}\n` });
|
||||
}
|
||||
|
|
@ -190,49 +186,21 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('prints guided JSON for agent semantic-layer search outside a project through the built binary', async () => {
|
||||
const projectDir = join(tempDir, 'missing-search-project');
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
|
||||
const result = await runBuiltCli([
|
||||
'agent',
|
||||
'sl',
|
||||
'list',
|
||||
'--json',
|
||||
'--query',
|
||||
'revenue',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
]);
|
||||
it('rejects the removed agent command through the built binary', async () => {
|
||||
const result = await runBuiltCli(['agent']);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stdout).toBe('');
|
||||
const errorJson = parseJsonOutput<{
|
||||
ok: false;
|
||||
error: { code: string; message: string; nextSteps: string[] };
|
||||
}>(result.stderr);
|
||||
expect(errorJson).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KTX project at ${projectDir}.`,
|
||||
nextSteps: [
|
||||
`ktx setup --project-dir ${projectDir}`,
|
||||
`ktx status --project-dir ${projectDir}`,
|
||||
'ktx ingest run --connection-id <connection> --adapter <adapter>',
|
||||
`ktx agent sl list --json --query "revenue" --project-dir ${projectDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.stderr).toContain("unknown command 'agent'");
|
||||
});
|
||||
|
||||
it('runs doctor setup through the built binary', async () => {
|
||||
const result = await runBuiltCli(['status', '--no-input']);
|
||||
|
||||
expect(result.stdout).toContain('KTX setup doctor');
|
||||
expect(result.stdout).toMatch(/KTX (setup|project) doctor/);
|
||||
expect(result.stdout).toContain('Node 22+');
|
||||
expect(result.stdout).toContain('Workspace-local CLI');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(result.stderr === '' || result.stderr.startsWith('Project: ')).toBe(true);
|
||||
expect([0, 1]).toContain(result.code);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue