Merge remote-tracking branch 'origin/main' into add-codecov

# Conflicts:
#	packages/cli/src/doctor.test.ts
This commit is contained in:
Andrey Avtomonov 2026-05-14 01:07:09 +02:00
commit db6e4ccd18
41 changed files with 2702 additions and 1852 deletions

View file

@ -18,8 +18,6 @@ ktx setup [options]
| Flag | Description | Default |
|------|-------------|---------|
| `--project-dir <path>` | KTX project directory | `KTX_PROJECT_DIR`, nearest `ktx.yaml`, or cwd |
| `--new` | Create a new KTX project before setup | `false` |
| `--existing` | Use an existing KTX project | `false` |
| `--yes` | Accept safe defaults in non-interactive setup | `false` |
| `--no-input` | Disable interactive terminal input | — |
@ -29,76 +27,11 @@ ktx setup [options]
|------|-------------|---------|
| `--agents` | Install agent integration only | `false` |
| `--target <target>` | Agent target (`claude-code`, `codex`, `cursor`, `opencode`, `universal`) | — |
| `--agent-scope <scope>` | Agent install scope (`project` or `global`) | `project` |
| `--project` | Install agent integration into the project scope | `false` |
| `--global` | Install agent integration into the global target scope (Claude Code and Codex only) | `false` |
| `--skip-agents` | Leave agent integration incomplete for now | `false` |
### LLM Configuration
| Flag | Description | Default |
|------|-------------|---------|
| `--anthropic-api-key-env <name>` | Environment variable containing the Anthropic API key | — |
| `--anthropic-api-key-file <path>` | File containing the Anthropic API key | — |
| `--anthropic-model <model>` | Anthropic model ID to validate and save | — |
| `--skip-llm` | Leave LLM setup incomplete for now | `false` |
### Embedding Configuration
| Flag | Description | Default |
|------|-------------|---------|
| `--embedding-backend <backend>` | Embedding backend (`openai` or `sentence-transformers`) | — |
| `--embedding-api-key-env <name>` | Environment variable containing the embedding provider API key | — |
| `--embedding-api-key-file <path>` | File containing the embedding provider API key | — |
| `--skip-embeddings` | Leave embedding setup incomplete for now | `false` |
### Database Configuration
| Flag | Description | Default |
|------|-------------|---------|
| `--database <driver>` | Database driver to configure; repeatable (`sqlite`, `postgres`, `mysql`, `clickhouse`, `sqlserver`, `bigquery`, `snowflake`) | — |
| `--database-connection-id <id>` | Existing or new connection id; repeatable | — |
| `--new-database-connection-id <id>` | Connection id for one new database connection | — |
| `--database-url <url>` | URL, `env:NAME`, or `file:/path` for one new URL-style database connection | — |
| `--database-schema <schema>` | Database schema to include; repeatable | — |
| `--skip-databases` | Leave database setup incomplete | `false` |
### Historic SQL
| Flag | Description | Default |
|------|-------------|---------|
| `--enable-historic-sql` | Enable Historic SQL when the selected database supports it | `false` |
| `--disable-historic-sql` | Disable Historic SQL for the selected database | `false` |
| `--historic-sql-window-days <number>` | Historic SQL query-history window in days | — |
| `--historic-sql-min-executions <number>` | Minimum executions for a Historic SQL template | — |
| `--historic-sql-min-calls <number>` | Alias for `--historic-sql-min-executions` for one release | — |
| `--historic-sql-service-account-pattern <pattern>` | Historic SQL service-account regex; repeatable | — |
| `--historic-sql-redaction-pattern <pattern>` | Historic SQL SQL-literal redaction regex; repeatable | — |
### Context Source Configuration
| Flag | Description | Default |
|------|-------------|---------|
| `--source <type>` | Source connector type (`dbt`, `metricflow`, `metabase`, `looker`, `lookml`, `notion`) | — |
| `--source-connection-id <id>` | Connection id for source setup | — |
| `--source-path <path>` | Local source path for dbt, MetricFlow, or LookML | — |
| `--source-git-url <url>` | Git URL for dbt, MetricFlow, or LookML | — |
| `--source-branch <branch>` | Git branch for source setup | — |
| `--source-subpath <path>` | Repo subpath for source setup | — |
| `--source-auth-token-ref <ref>` | `env:` or `file:` credential ref for source repo auth | — |
| `--source-url <url>` | Source service URL for Metabase or Looker | — |
| `--source-api-key-ref <ref>` | `env:` or `file:` API key ref for Metabase or Notion | — |
| `--source-client-id <id>` | Looker client id | — |
| `--source-client-secret-ref <ref>` | `env:` or `file:` Looker client secret ref | — |
| `--source-warehouse-connection-id <id>` | Mapped warehouse connection id | — |
| `--source-project-name <name>` | dbt project name override | — |
| `--source-profiles-path <path>` | dbt profiles path | — |
| `--source-target <target>` | dbt target or source-specific mapping target | — |
| `--metabase-database-id <id>` | Metabase database id to map | — |
| `--notion-crawl-mode <mode>` | Notion crawl mode (`all_accessible` or `selected_roots`) | — |
| `--notion-root-page-id <id>` | Notion root page id; repeatable | — |
| `--skip-initial-source-ingest` | Validate source setup without building source context during setup | `false` |
| `--skip-sources` | Mark optional source setup complete with no sources | `false` |
The setup wizard is the public configuration interface. It prompts for LLM
credentials, embeddings, database connections, context sources, Historic SQL,
and agent integration when those values are needed.
## Examples
@ -106,17 +39,8 @@ ktx setup [options]
# Run the interactive setup wizard
ktx setup
# Create a new project and run setup
ktx setup --new
# Resume setup in an existing project
ktx setup --existing
# Non-interactive setup with Anthropic key from environment
ktx setup --yes --anthropic-api-key-env ANTHROPIC_API_KEY
# Set up a Postgres connection
ktx setup --database postgres --database-url "env:DATABASE_URL"
# Run setup for a specific project directory
ktx setup --project-dir ./analytics
# Install agent integration for Claude Code only
ktx setup --agents --target claude-code
@ -124,12 +48,6 @@ ktx setup --agents --target claude-code
# Install agent integration globally for Codex
ktx setup --agents --target codex --global
# Add a dbt source from a local path
ktx setup --source dbt --source-path ./my-dbt-project
# Skip optional steps for a minimal setup
ktx setup --skip-sources --skip-agents
# Check setup readiness
ktx status
```
@ -156,5 +74,5 @@ Agent integration ready: yes (codex:project)
|-------|-------|----------|
| Setup resumes an unexpected project | `KTX_PROJECT_DIR` or nearest `ktx.yaml` points to another directory | Pass `--project-dir <path>` explicitly |
| Health check for model fails | Provider key or model id is invalid | Set the correct environment variable or secret file and rerun setup |
| Setup cannot run in CI | Interactive prompts need a TTY | Use `--yes --no-input` with explicit flags for required values |
| Setup cannot run in CI | Interactive prompts need a TTY | Run setup interactively before CI, or provide a fixture `ktx.yaml` for automated tests |
| Agent integration missing | Setup skipped the agents step | Run `ktx setup --agents --target <target>` |

View file

@ -242,7 +242,7 @@ Agent integration ready: yes (claude-code:project)
| Local embeddings hang or fail | The managed Python runtime cannot start or the local model runtime is unavailable | Install `uv`, run `ktx dev runtime status`, then run `ktx dev runtime install --feature local-embeddings --yes` and rerun setup |
| Database connection test fails | Credentials, network access, warehouse, database, or schema value is wrong | Test the same URL with the database's native client, then rerun `ktx setup` and reconfigure the connection |
| `KTX context built: no` in `ktx status` | Setup saved configuration but did not build context | Run `ktx setup` and choose to build context now |
| Agent integration is incomplete | Setup skipped the agents step or the target was not installed | Run `ktx setup --agents --target codex --project` using the target you need |
| Agent integration is incomplete | Setup skipped the agents step or the target was not installed | Run `ktx setup --agents --target codex` using the target you need |
## Next steps

View file

@ -64,13 +64,6 @@ function sourceType(value: string): KtxSetupSourceType {
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
function agentScope(value: string): 'project' | 'global' {
if (value === 'project' || value === 'global') {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
}
function positiveNumber(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
@ -97,7 +90,6 @@ function shouldShowSetupEntryMenu(
agents?: boolean;
target?: string;
global?: boolean;
project?: boolean;
skipAgents?: boolean;
yes?: boolean;
input?: boolean;
@ -142,7 +134,6 @@ function shouldShowSetupEntryMenu(
metabaseDatabaseId?: number;
notionCrawlMode?: string;
notionRootPageId?: string[];
skipInitialSourceIngest?: boolean;
skipSources?: boolean;
},
command: Command,
@ -172,7 +163,6 @@ function shouldShowSetupEntryMenu(
'agents',
'target',
'global',
'project',
'skipAgents',
'yes',
'input',
@ -211,7 +201,6 @@ function shouldShowSetupEntryMenu(
'sourceTarget',
'metabaseDatabaseId',
'notionCrawlMode',
'skipInitialSourceIngest',
'skipSources',
].some((optionName) => optionWasSpecified(command, optionName));
}
@ -220,9 +209,9 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
const setup = program
.command('setup')
.description('Set up or resume a local KTX project')
.option('--project-dir <path>', 'KTX project directory')
.option('--new', 'Create a new KTX project before setup', false)
.option('--existing', 'Use an existing KTX project', false)
.addOption(new Option('--project-dir <path>', 'KTX project directory').hideHelp())
.addOption(new Option('--new', 'Create a new KTX project before setup').hideHelp().default(false))
.addOption(new Option('--existing', 'Use an existing KTX project').hideHelp().default(false))
.option('--agents', 'Install agent integration only', false)
.addOption(
new Option('--target <target>', 'Agent target').choices([
@ -233,94 +222,124 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
'universal',
]),
)
.addOption(new Option('--agent-scope <scope>', 'Agent install scope').argParser(agentScope).default('project'))
.option('--project', 'Install agent integration into the project scope', false)
.option('--global', 'Install agent integration into the global target scope', false)
.option('--skip-agents', 'Leave agent integration incomplete for now', false)
.addOption(new Option('--skip-agents', 'Leave agent integration incomplete for now').hideHelp().default(false))
.option('--yes', 'Accept safe defaults in non-interactive setup', false)
.option('--no-input', 'Disable interactive terminal input')
.addOption(new Option('--llm-backend <backend>', 'LLM backend').argParser(llmBackend))
.option('--anthropic-api-key-env <name>', 'Environment variable containing the Anthropic API key')
.option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key')
.option('--anthropic-model <model>', 'Anthropic model ID to validate and save')
.option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path')
.option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path')
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
.addOption(new Option('--embedding-backend <backend>', 'Embedding backend').argParser(embeddingBackend))
.option('--embedding-api-key-env <name>', 'Environment variable containing the embedding provider API key')
.option('--embedding-api-key-file <path>', 'File containing the embedding provider API key')
.addOption(new Option('--skip-embeddings', 'Leave embedding setup incomplete for now').hideHelp().default(false))
.option(
'--database <driver>',
'Database driver to configure; repeatable',
(value, previous: KtxSetupDatabaseDriver[]) => {
return [...previous, databaseDriver(value)];
},
[] as KtxSetupDatabaseDriver[],
)
.option(
'--database-connection-id <id>',
'Existing selected connection id or new connection id',
(value, previous: string[]) => [...previous, value],
[],
)
.option('--new-database-connection-id <id>', 'Connection id for one new database connection', (value) => {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
}
return value;
})
.option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection')
.option(
'--database-schema <schema>',
'Database schema to include; repeatable',
(value, previous: string[]) => [...previous, value],
[],
)
.option('--enable-historic-sql', 'Enable Historic SQL when the selected database supports it', false)
.option('--disable-historic-sql', 'Disable Historic SQL for the selected database', false)
.option('--historic-sql-window-days <number>', 'Historic SQL query-history window', positiveInteger)
.option('--historic-sql-min-executions <number>', 'Minimum Historic SQL executions for a template', positiveInteger)
.option(
'--historic-sql-service-account-pattern <pattern>',
'Historic SQL service-account regex; repeatable',
(value, previous: string[]) => [...previous, value],
[],
)
.option(
'--historic-sql-redaction-pattern <pattern>',
'Historic SQL SQL-literal redaction regex; repeatable',
(value, previous: string[]) => [...previous, value],
[],
)
.option('--skip-databases', 'Leave database setup incomplete; KTX cannot work until a primary source is added', false)
.addOption(new Option('--source <type>', 'Source connector type').argParser(sourceType))
.option('--source-connection-id <id>', 'Connection id for source setup')
.option('--source-path <path>', 'Local source path for dbt, MetricFlow, or LookML')
.option('--source-git-url <url>', 'Git URL for dbt, MetricFlow, or LookML')
.option('--source-branch <branch>', 'Git branch for source setup')
.option('--source-subpath <path>', 'Repo subpath for source setup')
.option('--source-auth-token-ref <ref>', 'env: or file: credential ref for source repo auth')
.option('--source-url <url>', 'Source service URL for Metabase or Looker')
.option('--source-api-key-ref <ref>', 'env: or file: API key ref for Metabase or Notion')
.option('--source-client-id <id>', 'Looker client id')
.option('--source-client-secret-ref <ref>', 'env: or file: Looker client secret ref')
.option('--source-warehouse-connection-id <id>', 'Mapped warehouse connection id')
.option('--source-project-name <name>', 'dbt project name override')
.option('--source-profiles-path <path>', 'dbt profiles path')
.option('--source-target <target>', 'dbt target or source-specific mapping target')
.option('--metabase-database-id <id>', 'Metabase database id to map', positiveNumber)
.addOption(new Option('--llm-backend <backend>', 'LLM backend').argParser(llmBackend).hideHelp())
.addOption(
new Option('--notion-crawl-mode <mode>', 'Notion crawl mode').choices(['all_accessible', 'selected_roots']),
new Option('--anthropic-api-key-env <name>', 'Environment variable containing the Anthropic API key').hideHelp(),
)
.option(
'--notion-root-page-id <id>',
'Notion root page id; repeatable',
(value, previous: string[]) => [...previous, value],
[],
.addOption(
new Option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key').hideHelp(),
)
.option('--skip-initial-source-ingest', 'Validate source setup without building source context during setup', false)
.option('--skip-sources', 'Mark optional source setup complete with no sources', false)
.addOption(new Option('--anthropic-model <model>', 'Anthropic model ID to validate and save').hideHelp())
.addOption(new Option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp())
.addOption(new Option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp())
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
.addOption(new Option('--embedding-backend <backend>', 'Embedding backend').argParser(embeddingBackend).hideHelp())
.addOption(
new Option(
'--embedding-api-key-env <name>',
'Environment variable containing the embedding provider API key',
).hideHelp(),
)
.addOption(
new Option('--embedding-api-key-file <path>', 'File containing the embedding provider API key').hideHelp(),
)
.addOption(new Option('--skip-embeddings', 'Leave embedding setup incomplete for now').hideHelp().default(false))
.addOption(
new Option('--database <driver>', 'Database driver to configure; repeatable')
.argParser((value, previous: KtxSetupDatabaseDriver[]) => {
return [...previous, databaseDriver(value)];
})
.default([] as KtxSetupDatabaseDriver[])
.hideHelp(),
)
.addOption(
new Option('--database-connection-id <id>', 'Existing selected connection id or new connection id')
.argParser((value, previous: string[]) => [...previous, value])
.default([] as string[])
.hideHelp(),
)
.addOption(
new Option('--new-database-connection-id <id>', 'Connection id for one new database connection')
.argParser((value) => {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
}
return value;
})
.hideHelp(),
)
.addOption(
new Option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection').hideHelp(),
)
.addOption(
new Option('--database-schema <schema>', 'Database schema to include; repeatable')
.argParser((value, previous: string[]) => [...previous, value])
.default([] as string[])
.hideHelp(),
)
.addOption(
new Option('--enable-historic-sql', 'Enable Historic SQL when the selected database supports it')
.hideHelp()
.default(false),
)
.addOption(
new Option('--disable-historic-sql', 'Disable Historic SQL for the selected database').hideHelp().default(false),
)
.addOption(new Option('--historic-sql-window-days <number>', 'Historic SQL query-history window').argParser(positiveInteger).hideHelp())
.addOption(
new Option('--historic-sql-min-executions <number>', 'Minimum Historic SQL executions for a template')
.argParser(positiveInteger)
.hideHelp(),
)
.addOption(
new Option('--historic-sql-service-account-pattern <pattern>', 'Historic SQL service-account regex; repeatable')
.argParser((value, previous: string[]) => [...previous, value])
.default([] as string[])
.hideHelp(),
)
.addOption(
new Option('--historic-sql-redaction-pattern <pattern>', 'Historic SQL SQL-literal redaction regex; repeatable')
.argParser((value, previous: string[]) => [...previous, value])
.default([] as string[])
.hideHelp(),
)
.addOption(
new Option('--skip-databases', 'Leave database setup incomplete; KTX cannot work until a primary source is added')
.hideHelp()
.default(false),
)
.addOption(new Option('--source <type>', 'Source connector type').argParser(sourceType).hideHelp())
.addOption(new Option('--source-connection-id <id>', 'Connection id for source setup').hideHelp())
.addOption(new Option('--source-path <path>', 'Local source path for dbt, MetricFlow, or LookML').hideHelp())
.addOption(new Option('--source-git-url <url>', 'Git URL for dbt, MetricFlow, or LookML').hideHelp())
.addOption(new Option('--source-branch <branch>', 'Git branch for source setup').hideHelp())
.addOption(new Option('--source-subpath <path>', 'Repo subpath for source setup').hideHelp())
.addOption(new Option('--source-auth-token-ref <ref>', 'env: or file: credential ref for source repo auth').hideHelp())
.addOption(new Option('--source-url <url>', 'Source service URL for Metabase or Looker').hideHelp())
.addOption(new Option('--source-api-key-ref <ref>', 'env: or file: API key ref for Metabase or Notion').hideHelp())
.addOption(new Option('--source-client-id <id>', 'Looker client id').hideHelp())
.addOption(new Option('--source-client-secret-ref <ref>', 'env: or file: Looker client secret ref').hideHelp())
.addOption(new Option('--source-warehouse-connection-id <id>', 'Mapped warehouse connection id').hideHelp())
.addOption(new Option('--source-project-name <name>', 'dbt project name override').hideHelp())
.addOption(new Option('--source-profiles-path <path>', 'dbt profiles path').hideHelp())
.addOption(new Option('--source-target <target>', 'dbt target or source-specific mapping target').hideHelp())
.addOption(new Option('--metabase-database-id <id>', 'Metabase database id to map').argParser(positiveNumber).hideHelp())
.addOption(
new Option('--notion-crawl-mode <mode>', 'Notion crawl mode')
.choices(['all_accessible', 'selected_roots'])
.hideHelp(),
)
.addOption(
new Option('--notion-root-page-id <id>', 'Notion root page id; repeatable')
.argParser((value, previous: string[]) => [...previous, value])
.default([] as string[])
.hideHelp(),
)
.addOption(new Option('--skip-sources', 'Mark optional source setup complete with no sources').hideHelp().default(false))
.showHelpAfterError();
setup.hook('preAction', (_thisCommand, actionCommand) => {
@ -371,7 +390,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
}
const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto';
const resolvedAgentScope = options.global ? 'global' : options.agentScope;
const resolvedAgentScope = options.global ? 'global' : 'project';
await runSetupArgs(context, {
command: 'run',
projectDir: resolveCommandProjectDir(command),

View file

@ -16,8 +16,9 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
.command('status')
.description('Check current KTX setup and project readiness')
.option('--json', 'Print JSON output', false)
.option('-v, --verbose', 'Show every check, including passing ones', false)
.option('--no-input', 'Disable interactive terminal input')
.action(async (options: { json?: boolean; input?: boolean }, command) => {
.action(async (options: { json?: boolean; verbose?: boolean; input?: boolean }, command) => {
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
const explicitOrEnvProjectDir = resolveCommandProjectDirOverride(command);
const nearestProjectDir = explicitOrEnvProjectDir ? undefined : findNearestKtxProjectDir(process.cwd());
@ -27,6 +28,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
{
command: 'setup',
outputMode: outputMode(options),
verbose: options.verbose === true,
...inputMode(options),
},
context.io,
@ -40,6 +42,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
command: 'project',
projectDir: resolveCommandProjectDir(command),
outputMode: outputMode(options),
verbose: options.verbose === true,
...inputMode(options),
},
context.io,

View file

@ -0,0 +1,188 @@
import { describe, expect, it, vi } from 'vitest';
import {
pickDatabaseScope,
type DatabaseTreePickerRenderer,
type PickDatabaseScopeArgs,
} from './database-tree-picker.js';
import type { TreePickerChrome, TreePickerResult } from './tree-picker-tui.js';
import type { PickerState } from './tree-picker-state.js';
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: { isTTY: true, write: (chunk: string) => { stdout += chunk; } },
stderr: { write: (chunk: string) => { stderr += chunk; } },
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function captureRenderer(): {
renderer: DatabaseTreePickerRenderer;
capture: { chrome?: TreePickerChrome; state?: PickerState };
setResult: (result: TreePickerResult) => void;
} {
const capture: { chrome?: TreePickerChrome; state?: PickerState } = {};
let nextResult: TreePickerResult = { kind: 'quit' };
const renderer: DatabaseTreePickerRenderer = vi.fn(async (chrome, state) => {
capture.chrome = chrome;
capture.state = state;
return nextResult;
});
return {
renderer,
capture,
setResult: (result) => {
nextResult = result;
},
};
}
const discovered = [
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
{ schema: 'analytics', name: 'orders', kind: 'table' as const },
{ schema: 'public', name: 'events', kind: 'view' as const },
{ schema: 'public', name: 'sessions', kind: 'table' as const },
];
function baseArgs(overrides: Partial<PickDatabaseScopeArgs> = {}): PickDatabaseScopeArgs {
return {
connectionId: 'warehouse',
schemaNoun: 'schema',
schemaNounPlural: 'schemas',
discovered,
existing: { enabledTables: [] },
defaultSchemas: ['analytics'],
supportsSchemaScope: true,
...overrides,
};
}
describe('pickDatabaseScope', () => {
it('builds a 2-level tree (schemas as parents, tables as children) and uses save-empty action', async () => {
const { renderer, capture, setResult } = captureRenderer();
setResult({ kind: 'quit' });
await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
expect(capture.state?.skipEmptyAction).toBe('save-empty');
const schemaIds = capture.state?.tree.filter((n) => n.parentId === null).map((n) => n.id);
const tableIds = capture.state?.tree.filter((n) => n.parentId !== null).map((n) => n.id);
expect((schemaIds ?? []).sort()).toEqual(['analytics', 'public']);
expect((tableIds ?? []).sort()).toEqual([
'analytics.customers',
'analytics.orders',
'public.events',
'public.sessions',
]);
expect(capture.state?.byId.get('public.events')?.title).toBe('events (view)');
});
it('pre-checks default schemas at the parent level when no existing selection', async () => {
const { renderer, capture, setResult } = captureRenderer();
setResult({ kind: 'quit' });
await pickDatabaseScope(baseArgs({ defaultSchemas: ['analytics'] }), makeIo().io, renderer);
expect([...(capture.state?.checked ?? [])]).toEqual(['analytics']);
});
it('collapses an existing full-schema selection back into the parent check', async () => {
const { renderer, capture, setResult } = captureRenderer();
setResult({ kind: 'quit' });
await pickDatabaseScope(
baseArgs({ existing: { enabledTables: ['analytics.customers', 'analytics.orders'] } }),
makeIo().io,
renderer,
);
expect([...(capture.state?.checked ?? [])]).toEqual(['analytics']);
});
it('keeps a partial existing selection at the leaf level', async () => {
const { renderer, capture, setResult } = captureRenderer();
setResult({ kind: 'quit' });
await pickDatabaseScope(
baseArgs({ existing: { enabledTables: ['analytics.customers'] } }),
makeIo().io,
renderer,
);
expect([...(capture.state?.checked ?? [])]).toEqual(['analytics.customers']);
});
it('expands a selected schema parent into all its tables and derives activeSchemas', async () => {
const { renderer, setResult } = captureRenderer();
setResult({ kind: 'save', selectedIds: ['analytics'] });
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
expect(result).toEqual({
kind: 'selected',
activeSchemas: ['analytics'],
enabledTables: ['analytics.customers', 'analytics.orders'],
});
});
it('combines parent and individual leaf selections without duplicate tables', async () => {
const { renderer, setResult } = captureRenderer();
setResult({ kind: 'save', selectedIds: ['analytics', 'public.events'] });
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
expect(result).toEqual({
kind: 'selected',
activeSchemas: ['analytics', 'public'],
enabledTables: ['analytics.customers', 'analytics.orders', 'public.events'],
});
});
it('treats empty save as enable-all', async () => {
const { renderer, setResult } = captureRenderer();
setResult({ kind: 'save', selectedIds: [] });
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
expect(result).toEqual({
kind: 'selected',
activeSchemas: ['analytics', 'public'],
enabledTables: [
'analytics.customers',
'analytics.orders',
'public.events',
'public.sessions',
],
});
});
it('omits activeSchemas when the driver does not support a schema scope', async () => {
const { renderer, setResult } = captureRenderer();
setResult({ kind: 'save', selectedIds: ['analytics'] });
const result = await pickDatabaseScope(
baseArgs({ supportsSchemaScope: false }),
makeIo().io,
renderer,
);
expect(result).toEqual({
kind: 'selected',
activeSchemas: [],
enabledTables: ['analytics.customers', 'analytics.orders'],
});
});
it('returns back when the picker quits', async () => {
const { renderer, setResult } = captureRenderer();
setResult({ kind: 'quit' });
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
expect(result).toEqual({ kind: 'back' });
});
});

View file

@ -0,0 +1,210 @@
import type { KtxTableListEntry } from '@ktx/context/scan';
import type { KtxCliIo } from './cli-runtime.js';
import { profileMark } from './startup-profile.js';
import {
buildInitialState,
buildPickerTree,
type PickerState,
type TreePickerNode,
type TreePickerNodeInput,
} from './tree-picker-state.js';
import {
renderTreePickerTui,
type TreePickerChrome,
type TreePickerResult,
type TreePickerTuiIo,
} from './tree-picker-tui.js';
profileMark('module:database-tree-picker');
const DATABASE_SCRIPTED_MODE_HINT =
'Database picker requires a TTY. Use --no-input and the relevant flags for scripted mode.';
export type DatabaseTreePickerRenderer = (
chrome: TreePickerChrome,
initialState: PickerState,
io: TreePickerTuiIo,
) => Promise<TreePickerResult>;
function defaultRenderer(
chrome: TreePickerChrome,
initialState: PickerState,
io: TreePickerTuiIo,
): Promise<TreePickerResult> {
return renderTreePickerTui({ chrome, initialState }, io, { scriptedModeHint: DATABASE_SCRIPTED_MODE_HINT });
}
export type DatabaseScopePickResult =
| { kind: 'selected'; activeSchemas: string[]; enabledTables: string[] }
| { kind: 'back' };
export interface PickDatabaseScopeArgs {
connectionId: string;
schemaNoun: string;
schemaNounPlural: string;
discovered: readonly KtxTableListEntry[];
existing: { enabledTables: readonly string[] };
defaultSchemas: readonly string[];
supportsSchemaScope: boolean;
}
function qualifiedTableId(entry: KtxTableListEntry): string {
return `${entry.schema}.${entry.name}`;
}
function tableTitle(entry: KtxTableListEntry): string {
return entry.kind === 'view' ? `${entry.name} (view)` : entry.name;
}
function buildTreeInputs(discovered: readonly KtxTableListEntry[]): {
inputs: TreePickerNodeInput[];
schemaIds: string[];
allTables: string[];
} {
const schemaSeen = new Set<string>();
const schemaIds: string[] = [];
for (const entry of discovered) {
if (!schemaSeen.has(entry.schema)) {
schemaSeen.add(entry.schema);
schemaIds.push(entry.schema);
}
}
const inputs: TreePickerNodeInput[] = [];
for (const schema of schemaIds) {
inputs.push({ id: schema, title: schema, archived: false, parentId: null });
}
for (const entry of discovered) {
inputs.push({
id: qualifiedTableId(entry),
title: tableTitle(entry),
archived: false,
parentId: entry.schema,
});
}
return { inputs, schemaIds, allTables: discovered.map(qualifiedTableId) };
}
function initialSelectionForExisting(
existing: readonly string[],
byId: Map<string, TreePickerNode>,
): string[] {
const tableIds = new Set(
[...byId.values()].filter((node) => node.parentId !== null).map((node) => node.id),
);
const existingTables = new Set(existing.filter((id) => tableIds.has(id)));
const schemaChildren = new Map<string, string[]>();
for (const node of byId.values()) {
if (node.parentId === null && node.childIds.length > 0) {
schemaChildren.set(node.id, [...node.childIds]);
}
}
const result: string[] = [];
for (const [schema, children] of schemaChildren) {
const allChecked = children.length > 0 && children.every((childId) => existingTables.has(childId));
if (allChecked) {
result.push(schema);
for (const childId of children) {
existingTables.delete(childId);
}
}
}
for (const id of existingTables) {
result.push(id);
}
return result;
}
function initialSelectionFromDefaults(
defaultSchemas: readonly string[],
schemaIds: readonly string[],
): string[] {
const valid = new Set(schemaIds);
const filtered = defaultSchemas.filter((s) => valid.has(s));
return filtered.length > 0 ? filtered : [...schemaIds];
}
function expandSelectedToTables(
selectedIds: readonly string[],
byId: Map<string, TreePickerNode>,
): string[] {
const expanded: string[] = [];
const seen = new Set<string>();
for (const id of selectedIds) {
const node = byId.get(id);
if (!node) continue;
if (node.childIds.length === 0) {
if (node.parentId !== null && !seen.has(id)) {
seen.add(id);
expanded.push(id);
}
continue;
}
for (const childId of node.childIds) {
if (!seen.has(childId)) {
seen.add(childId);
expanded.push(childId);
}
}
}
return expanded;
}
function schemasFromEnabledTables(enabledTables: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const qualified of enabledTables) {
const schema = qualified.split('.')[0] ?? '';
if (schema.length === 0 || seen.has(schema)) continue;
seen.add(schema);
result.push(schema);
}
return result;
}
export async function pickDatabaseScope(
args: PickDatabaseScopeArgs,
io: KtxCliIo,
render: DatabaseTreePickerRenderer = defaultRenderer,
): Promise<DatabaseScopePickResult> {
const { inputs, schemaIds, allTables } = buildTreeInputs(args.discovered);
const tree = buildPickerTree(inputs);
const byId = new Map(tree.map((node) => [node.id, node]));
const tableCount = allTables.length;
const schemaCount = schemaIds.length;
const initialSelection =
args.existing.enabledTables.length > 0
? initialSelectionForExisting(args.existing.enabledTables, byId)
: initialSelectionFromDefaults(args.defaultSchemas, schemaIds);
const initialState = buildInitialState({
tree,
existingSelectedIds: initialSelection,
skipEmptyAction: 'save-empty',
});
const schemaWordPlural = schemaCount === 1 ? args.schemaNoun : args.schemaNounPlural;
const subtitleLines = [
`Connection: ${args.connectionId}`,
`Found ${tableCount} ${tableCount === 1 ? 'table' : 'tables'} across ${schemaCount} ${schemaWordPlural}.`,
`Toggle a ${args.schemaNoun} to enable all of its tables, or expand to pick individual tables.`,
];
const chrome: TreePickerChrome = {
title: `Choose tables to enable for ${args.connectionId}`,
subtitleLines,
skipEmptyMessage:
'Nothing selected. Enable all tables? Press Enter to enable all or Escape to go back.',
};
const result = await render(chrome, initialState, io as TreePickerTuiIo);
if (result.kind === 'quit') {
return { kind: 'back' };
}
const enabledTables =
result.selectedIds.length === 0 ? allTables : expandSelectedToTables(result.selectedIds, byId);
const activeSchemas = args.supportsSchemaScope ? schemasFromEnabledTables(enabledTables) : [];
return { kind: 'selected', activeSchemas, enabledTables };
}

View file

@ -1,8 +1,7 @@
import { mkdtemp, 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 type { KtxEmbeddingConfig, KtxEmbeddingHealthCheckOptions, KtxEmbeddingHealthCheckResult } from '@ktx/llm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
formatDoctorReport,
runKtxDoctor,
@ -31,53 +30,65 @@ function makeIo() {
};
}
type EmbeddingHealthCheck = (
config: KtxEmbeddingConfig,
options?: KtxEmbeddingHealthCheckOptions,
) => Promise<KtxEmbeddingHealthCheckResult>;
async function writeProjectConfig(projectDir: string, embeddingLines: string[]): Promise<void> {
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'ingest:',
' adapters:',
' - live-database',
' embeddings:',
...embeddingLines.map((line) => ` ${line}`),
'',
].join('\n'),
'utf-8',
);
}
describe('formatDoctorReport', () => {
it('prints exact fixes for failing setup checks', () => {
it('shows the failing check and its fix in plain output', () => {
const checks: DoctorCheck[] = [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127', group: 'toolchain' },
{
id: 'native-sqlite',
label: 'Native SQLite',
status: 'fail',
detail: 'Cannot load better-sqlite3',
fix: 'Run: pnpm run native:rebuild',
group: 'toolchain',
},
];
expect(formatDoctorReport({ title: 'KTX setup doctor', checks })).toBe(
[
'KTX setup doctor',
'PASS Node 22+: v22.16.0 ABI 127',
'FAIL Native SQLite: Cannot load better-sqlite3',
' Fix: Run: pnpm run native:rebuild',
'',
].join('\n'),
);
const output = formatDoctorReport({ title: 'KTX status', checks });
expect(output).toContain('KTX status');
expect(output).toContain('✗ Environment');
expect(output).toContain('1 of 2 need attention');
expect(output).toContain('✗ Native SQLite: Cannot load better-sqlite3');
expect(output).toContain('→ Run: pnpm run native:rebuild');
expect(output).toContain('1 issue to fix.');
});
it('lists what was checked when a group has all passing checks', () => {
const checks: DoctorCheck[] = [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
{ id: 'pnpm', label: 'pnpm 10.20+', status: 'pass', detail: '10.28.0', group: 'toolchain' },
];
const output = formatDoctorReport({ title: 'KTX status', checks });
expect(output).toContain('✓ Environment');
expect(output).toContain('Node 22+ · pnpm 10.20+');
expect(output).not.toContain('v22.16.0');
expect(output).toContain('Everything ready.');
});
it('shows the underlying detail for a single-check group on the group line', () => {
const checks: DoctorCheck[] = [
{
id: 'semantic-search-embeddings',
label: 'Semantic search embeddings',
status: 'pass',
detail: 'openai/text-embedding-3-small (1536d) probe succeeded',
group: 'search',
},
];
const output = formatDoctorReport({ title: 'KTX status', checks });
expect(output).toContain('✓ Semantic search');
expect(output).toContain('openai/text-embedding-3-small (1536d) probe succeeded');
});
it('lists every check in verbose mode', () => {
const checks: DoctorCheck[] = [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
];
const output = formatDoctorReport({ title: 'KTX status', checks }, { verbose: true });
expect(output).toContain('✓ Node 22+: v22.16.0');
});
});
@ -127,6 +138,7 @@ describe('runSetupDoctorChecks', () => {
status: 'fail',
detail: 'pnpm not found',
fix: 'Run: corepack enable && corepack prepare pnpm@10.28.0 --activate',
group: 'toolchain',
});
expect(checks).toContainEqual({
id: 'package-build',
@ -134,6 +146,7 @@ describe('runSetupDoctorChecks', () => {
status: 'fail',
detail: 'Missing packages/cli/dist/bin.js',
fix: 'Run: pnpm run build',
group: 'toolchain',
});
});
@ -154,9 +167,11 @@ describe('runSetupDoctorChecks', () => {
const testIo = makeIo();
await expect(
runKtxDoctor({ command: 'setup', outputMode: 'plain', inputMode: 'disabled' }, testIo.io, {
runSetupChecks: async () => checks,
}),
runKtxDoctor(
{ command: 'setup', outputMode: 'plain', inputMode: 'disabled', verbose: true },
testIo.io,
{ runSetupChecks: async () => checks },
),
).resolves.toBe(0);
expect(checks).toContainEqual({
@ -165,8 +180,9 @@ describe('runSetupDoctorChecks', () => {
status: 'warn',
detail: 'spawn corepack ENOENT',
fix: 'Run: corepack enable',
group: 'toolchain',
});
expect(testIo.stdout()).toContain('WARN Corepack: spawn corepack ENOENT');
expect(testIo.stdout()).toContain(' Corepack: spawn corepack ENOENT');
expect(testIo.stderr()).toBe('');
});
});
@ -204,12 +220,45 @@ describe('runKtxDoctor', () => {
),
).resolves.toBe(1);
expect(testIo.stdout()).toContain('KTX setup doctor');
expect(testIo.stdout()).toContain('FAIL TypeScript package build: Missing packages/cli/dist/bin.js');
expect(testIo.stdout()).toContain('Fix: Run: pnpm run build');
expect(testIo.stdout()).toContain('KTX status');
expect(testIo.stdout()).toContain('No project here yet.');
expect(testIo.stdout()).toContain('Before you can run');
expect(testIo.stdout()).toContain('✗ TypeScript package build: Missing packages/cli/dist/bin.js');
expect(testIo.stdout()).toContain('→ Run: pnpm run build');
expect(testIo.stderr()).toBe('');
});
it('leads with `ktx setup` and hides toolchain warnings when no project exists', async () => {
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'setup', outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0', group: 'toolchain' },
{
id: 'corepack',
label: 'Corepack',
status: 'warn',
detail: 'spawn corepack ENOENT',
fix: 'Run: corepack enable',
group: 'toolchain',
},
],
},
),
).resolves.toBe(0);
const out = testIo.stdout();
expect(out).toContain('No project here yet.');
expect(out).toContain('Run');
expect(out).toContain('ktx setup');
expect(out).not.toContain('Corepack');
expect(out).not.toContain('Node 22+');
});
it('prints JSON setup report', async () => {
const testIo = makeIo();
@ -226,12 +275,13 @@ describe('runKtxDoctor', () => {
).resolves.toBe(0);
expect(JSON.parse(testIo.stdout())).toEqual({
title: 'KTX setup doctor',
title: 'KTX status',
checks: [{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' }],
});
});
it('runs project checks against a valid ktx.yaml', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
@ -240,220 +290,109 @@ describe('runKtxDoctor', () => {
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
' models:',
' default: claude-sonnet-4-5',
'ingest:',
' adapters:',
' - live-database',
' embeddings:',
' backend: openai',
' model: text-embedding-3-small',
' dimensions: 1536',
'',
].join('\n'),
'utf-8',
);
process.env.OPENAI_API_KEY = 'test-key'; // pragma: allowlist secret
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
},
{},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('KTX project doctor');
expect(testIo.stdout()).toContain('PASS Project config: warehouse');
expect(testIo.stdout()).toContain('PASS Connections: 1 configured');
const out = testIo.stdout();
expect(out).toContain('KTX status');
expect(out).toContain('· warehouse');
expect(out).toContain('Connections (1)');
expect(out).toContain('LLM');
expect(out).toContain('anthropic');
expect(out).toContain('Embeddings');
expect(out).toContain('Ready.');
delete process.env.ANTHROPIC_API_KEY;
delete process.env.OPENAI_API_KEY;
});
it('includes Postgres historic-SQL readiness in project doctor output', async () => {
it('returns blocked verdict when LLM is not configured', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:WAREHOUSE_DATABASE_URL',
' historicSql:',
' enabled: true',
' dialect: postgres',
'ingest:',
' adapters:',
' - live-database',
' - historic-sql', // pragma: allowlist secret
' driver: sqlite',
' path: ./warehouse.db',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
const runHistoricSqlDoctorChecks = vi.fn(async () => [
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'pass' as const,
detail:
'pg_stat_statements ready (PostgreSQL 16.4); info: pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
},
]);
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
runHistoricSqlDoctorChecks,
},
{},
),
).resolves.toBe(0);
).resolves.toBe(1);
expect(runHistoricSqlDoctorChecks).toHaveBeenCalledTimes(1);
expect(testIo.stdout()).toContain('PASS Postgres Historic SQL (warehouse): pg_stat_statements ready');
expect(testIo.stdout()).toContain('info: pg_stat_statements.max is 1000');
expect(testIo.stdout()).not.toContain('Fix: Update the Postgres parameter group or config');
expect(testIo.stdout()).toContain('no LLM configured');
expect(testIo.stdout()).toContain('ktx setup');
});
it('warns when semantic-search embeddings are not configured', async () => {
await writeProjectConfig(tempDir, ['backend: deterministic', 'model: deterministic', 'dimensions: 8']);
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: warehouse',
'connections:',
' warehouse:',
' driver: sqlite',
' path: ./warehouse.db',
'llm:',
' provider:',
' backend: anthropic',
'ingest:',
' adapters:',
' - live-database',
' embeddings:',
' backend: deterministic',
' model: deterministic',
' dimensions: 8',
'',
].join('\n'),
'utf-8',
);
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
},
{},
),
).resolves.toBe(0);
expect(testIo.stdout()).toContain('WARN Semantic search embeddings: ingest.embeddings.backend is deterministic.');
expect(testIo.stdout()).toContain(
'Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
);
expect(testIo.stdout()).toContain(
`Fix: Run: ktx setup --project-dir ${tempDir} --no-input`,
);
});
it('probes configured semantic-search embeddings for project doctor', async () => {
await writeProjectConfig(tempDir, [
'backend: sentence-transformers',
'model: all-MiniLM-L6-v2',
'dimensions: 384',
'sentenceTransformers:',
' base_url: http://127.0.0.1:8765',
" pathPrefix: ''",
]);
const healthCheck = vi.fn<EmbeddingHealthCheck>(async () => ({ ok: true }));
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
embeddingHealthCheck: healthCheck,
embeddingProbeTimeoutMs: 1234,
},
),
).resolves.toBe(0);
expect(healthCheck).toHaveBeenCalledWith(
{
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { baseURL: 'http://127.0.0.1:8765', pathPrefix: '' },
},
{ text: 'KTX semantic search doctor probe', timeoutMs: 1234 },
);
expect(testIo.stdout()).toContain(
'PASS Semantic search embeddings: sentence-transformers/all-MiniLM-L6-v2 (384d) probe succeeded',
);
});
it('allows local sentence-transformers semantic-search probes enough time for cold start', async () => {
await writeProjectConfig(tempDir, [
'backend: sentence-transformers',
'model: all-MiniLM-L6-v2',
'dimensions: 384',
'sentenceTransformers:',
' base_url: http://127.0.0.1:8765',
" pathPrefix: ''",
]);
const healthCheck = vi.fn<EmbeddingHealthCheck>(async () => ({ ok: true }));
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
embeddingHealthCheck: healthCheck,
},
),
).resolves.toBe(0);
expect(healthCheck).toHaveBeenCalledWith(
expect.objectContaining({
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
}),
{ text: 'KTX semantic search doctor probe', timeoutMs: 120_000 },
);
});
it('reports unhealthy semantic-search embeddings as a warning in JSON output', async () => {
await writeProjectConfig(tempDir, [
'backend: sentence-transformers',
'model: all-MiniLM-L6-v2',
'dimensions: 384',
'sentenceTransformers:',
' base_url: http://127.0.0.1:8765',
" pathPrefix: ''",
]);
const healthCheck = vi.fn<EmbeddingHealthCheck>(async () => ({
ok: false,
message: 'connect ECONNREFUSED 127.0.0.1:8765',
}));
const testIo = makeIo();
await expect(
runKtxDoctor(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
testIo.io,
{
runSetupChecks: async () => [
{ id: 'node', label: 'Node 22+', status: 'pass', detail: 'v22.16.0 ABI 127' },
],
embeddingHealthCheck: healthCheck,
},
),
).resolves.toBe(0);
const report = JSON.parse(testIo.stdout()) as {
checks: Array<{ id: string; label: string; status: string; detail: string; fix?: string }>;
};
expect(report.checks).toContainEqual({
id: 'semantic-search-embeddings',
label: 'Semantic search embeddings',
status: 'warn',
detail:
'sentence-transformers/all-MiniLM-L6-v2 (384d) probe failed: connect ECONNREFUSED 127.0.0.1:8765. Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.',
fix: `Run: ktx setup --project-dir ${tempDir} --no-input`,
});
expect(testIo.stdout()).toContain('Embeddings');
expect(testIo.stdout()).toContain('deterministic');
expect(testIo.stdout()).toContain('semantic search degraded');
delete process.env.ANTHROPIC_API_KEY;
});
});

View file

@ -4,15 +4,13 @@ import { access } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { KtxLocalProject, KtxProjectEmbeddingConfig } from '@ktx/context/project';
import type { KtxEmbeddingConfig, KtxEmbeddingHealthCheckOptions, KtxEmbeddingHealthCheckResult } from '@ktx/llm';
import type { HistoricSqlDoctorDeps } from './historic-sql-doctor.js';
const execFileAsync = promisify(execFile);
type DoctorStatus = 'pass' | 'warn' | 'fail';
type KtxDoctorOutputMode = 'plain' | 'json';
type KtxDoctorInputMode = 'auto' | 'disabled';
type DoctorGroup = 'toolchain' | 'project' | 'search' | 'history';
export interface DoctorCheck {
id: string;
@ -20,6 +18,7 @@ export interface DoctorCheck {
status: DoctorStatus;
detail: string;
fix?: string;
group?: DoctorGroup;
}
interface DoctorReport {
@ -28,11 +27,22 @@ interface DoctorReport {
}
export type KtxDoctorArgs =
| { command: 'setup'; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode }
| { command: 'project'; projectDir: string; outputMode: KtxDoctorOutputMode; inputMode?: KtxDoctorInputMode };
| {
command: 'setup';
outputMode: KtxDoctorOutputMode;
inputMode?: KtxDoctorInputMode;
verbose?: boolean;
}
| {
command: 'project';
projectDir: string;
outputMode: KtxDoctorOutputMode;
inputMode?: KtxDoctorInputMode;
verbose?: boolean;
};
interface KtxDoctorIo {
stdout: { write(chunk: string): void };
stdout: { isTTY?: boolean; write(chunk: string): void };
stderr: { write(chunk: string): void };
}
@ -44,20 +54,8 @@ interface SetupDoctorDeps {
importBetterSqlite3?: () => Promise<unknown>;
}
type EmbeddingHealthCheck = (
config: KtxEmbeddingConfig,
options?: KtxEmbeddingHealthCheckOptions,
) => Promise<KtxEmbeddingHealthCheckResult>;
interface SemanticSearchDoctorDeps {
env?: NodeJS.ProcessEnv;
embeddingHealthCheck?: EmbeddingHealthCheck;
embeddingProbeTimeoutMs?: number;
}
interface KtxDoctorDeps extends SemanticSearchDoctorDeps, HistoricSqlDoctorDeps {
interface KtxDoctorDeps {
runSetupChecks?: () => Promise<DoctorCheck[]>;
runHistoricSqlDoctorChecks?: (project: KtxLocalProject, deps: HistoricSqlDoctorDeps) => Promise<DoctorCheck[]>;
}
function workspaceRootDir(): string {
@ -118,99 +116,6 @@ function check(status: DoctorStatus, id: string, label: string, detail: string,
return fix ? { id, label, status, detail, fix } : { id, label, status, detail };
}
const SEMANTIC_SEARCH_HEALTH_TEXT = 'KTX semantic search doctor probe';
const SEMANTIC_SEARCH_HEALTH_TIMEOUT_MS = 5_000;
const SEMANTIC_SEARCH_LOCAL_HEALTH_TIMEOUT_MS = 120_000;
function semanticEmbeddingSetupFix(projectDir: string, backend: KtxProjectEmbeddingConfig['backend']): string {
if (backend === 'openai') {
return `Set OPENAI_API_KEY or rerun: ktx setup --project-dir ${projectDir} --embedding-backend openai --no-input`;
}
return `Run: ktx setup --project-dir ${projectDir} --no-input`;
}
function embeddingConfigLabel(config: KtxProjectEmbeddingConfig | KtxEmbeddingConfig): string {
const model = config.model?.trim() || 'model not configured';
return `${config.backend}/${model} (${config.dimensions}d)`;
}
function semanticLaneFallbackDetail(reason: string): string {
return `${reason}. Semantic lane will be skipped; lexical, dictionary, and token lanes remain available.`;
}
async function defaultEmbeddingHealthCheck(
config: KtxEmbeddingConfig,
options?: KtxEmbeddingHealthCheckOptions,
): Promise<KtxEmbeddingHealthCheckResult> {
const { runKtxEmbeddingHealthCheck } = await import('@ktx/llm');
return runKtxEmbeddingHealthCheck(config, options);
}
async function runSemanticSearchEmbeddingCheck(
config: KtxProjectEmbeddingConfig,
projectDir: string,
deps: SemanticSearchDoctorDeps = {},
): Promise<DoctorCheck> {
if (config.backend === 'none' || config.backend === 'deterministic') {
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`ingest.embeddings.backend is ${config.backend}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
}
try {
const { resolveLocalKtxEmbeddingConfig } = await import('@ktx/context');
const resolved = resolveLocalKtxEmbeddingConfig(config, deps.env ?? process.env);
if (!resolved) {
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`No runtime embedding config resolved for ${embeddingConfigLabel(config)}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
}
const healthCheck = deps.embeddingHealthCheck ?? defaultEmbeddingHealthCheck;
const timeoutMs =
deps.embeddingProbeTimeoutMs ??
(resolved.backend === 'sentence-transformers'
? SEMANTIC_SEARCH_LOCAL_HEALTH_TIMEOUT_MS
: SEMANTIC_SEARCH_HEALTH_TIMEOUT_MS);
const health = await healthCheck(resolved, {
text: SEMANTIC_SEARCH_HEALTH_TEXT,
timeoutMs,
});
if (health.ok) {
return check(
'pass',
'semantic-search-embeddings',
'Semantic search embeddings',
`${embeddingConfigLabel(resolved)} probe succeeded`,
);
}
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`${embeddingConfigLabel(resolved)} probe failed: ${health.message}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
} catch (error) {
return check(
'warn',
'semantic-search-embeddings',
'Semantic search embeddings',
semanticLaneFallbackDetail(`${embeddingConfigLabel(config)} probe failed: ${failureMessage(error)}`),
semanticEmbeddingSetupFix(projectDir, config.backend),
);
}
}
export async function runSetupDoctorChecks(deps: SetupDoctorDeps = {}): Promise<DoctorCheck[]> {
const env = deps.env ?? process.env;
const root = deps.workspaceRoot ?? workspaceRootDir();
@ -304,56 +209,231 @@ export async function runSetupDoctorChecks(deps: SetupDoctorDeps = {}): Promise<
);
}
return checks;
return checks.map((entry) => ({ ...entry, group: 'toolchain' }));
}
async function runProjectChecks(projectDir: string, deps: KtxDoctorDeps = {}): Promise<DoctorCheck[]> {
const { loadKtxProject } = await import('@ktx/context/project');
const checks: DoctorCheck[] = [];
try {
const project = await loadKtxProject({ projectDir });
checks.push(check('pass', 'project-config', 'Project config', project.config.project));
const connectionCount = Object.keys(project.config.connections).length;
checks.push(
connectionCount > 0
? check('pass', 'connections', 'Connections', `${connectionCount} configured`)
: check(
'warn',
'connections',
'Connections',
'0 configured',
'Add a connection to ktx.yaml or run `ktx setup`',
),
);
checks.push(check('pass', 'storage', 'Storage', `${project.config.storage.state}/${project.config.storage.search}`));
checks.push(check('pass', 'llm-provider', 'LLM provider', project.config.llm.provider.backend));
checks.push(await runSemanticSearchEmbeddingCheck(project.config.ingest.embeddings, projectDir, deps));
const runHistoricSqlDoctorChecks =
deps.runHistoricSqlDoctorChecks ?? (await import('./historic-sql-doctor.js')).runPostgresHistoricSqlDoctorChecks;
checks.push(...(await runHistoricSqlDoctorChecks(project, deps)));
} catch (error) {
checks.push(
check(
'fail',
'project-config',
'Project config',
failureMessage(error),
`Run: ktx init ${projectDir} --name <project-name>`,
),
);
const STATUS_SYMBOL: Record<DoctorStatus, string> = { pass: '✓', warn: '⚠', fail: '✗' };
const GROUP_ORDER: DoctorGroup[] = ['toolchain', 'project', 'search', 'history'];
const GROUP_LABEL: Record<DoctorGroup, string> = {
toolchain: 'Environment',
project: 'Project',
search: 'Semantic search',
history: 'Query history',
};
function shouldUseColor(io: KtxDoctorIo): boolean {
if (io.stdout.isTTY !== true) return false;
const env = process.env;
return !env.NO_COLOR && env.TERM !== 'dumb' && !env.CI;
}
function styleStatus(useColor: boolean, status: DoctorStatus, text: string): string {
if (!useColor) return text;
const code = status === 'pass' ? 32 : status === 'warn' ? 33 : 31;
return `\u001b[${code}m${text}\u001b[39m`;
}
function styleDim(useColor: boolean, text: string): string {
return useColor ? `\u001b[2m${text}\u001b[22m` : text;
}
function styleBold(useColor: boolean, text: string): string {
return useColor ? `\u001b[1m${text}\u001b[22m` : text;
}
function groupOf(entry: DoctorCheck): DoctorGroup {
return entry.group ?? 'project';
}
function aggregateStatus(checks: DoctorCheck[]): DoctorStatus {
if (checks.some((c) => c.status === 'fail')) return 'fail';
if (checks.some((c) => c.status === 'warn')) return 'warn';
return 'pass';
}
function abbreviateHome(filePath: string | undefined): string | undefined {
if (!filePath) return filePath;
const home = process.env.HOME;
if (home && (filePath === home || filePath.startsWith(`${home}/`))) {
return filePath === home ? '~' : `~${filePath.slice(home.length)}`;
}
return checks;
return filePath;
}
export function formatDoctorReport(report: DoctorReport): string {
const lines = [report.title];
for (const item of report.checks) {
lines.push(`${item.status.toUpperCase()} ${item.label}: ${item.detail}`);
if (item.fix) {
lines.push(` Fix: ${item.fix}`);
function groupSummaryWhenAllPass(entries: DoctorCheck[]): string {
if (entries.length === 1) {
const only = entries[0]!;
return only.detail || only.label;
}
return entries.map((c) => c.label).join(' · ');
}
interface RenderOptions {
verbose: boolean;
useColor: boolean;
durationMs?: number;
projectName?: string;
projectDir?: string;
command?: 'setup' | 'project';
}
const NEXT_STEPS_PROJECT = ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'];
export function formatDoctorReport(report: DoctorReport, options: Partial<RenderOptions> = {}): string {
const opts: RenderOptions = {
verbose: options.verbose ?? false,
useColor: options.useColor ?? false,
durationMs: options.durationMs,
projectName: options.projectName,
projectDir: options.projectDir,
command: options.command,
};
return renderPlainReport(report, opts);
}
function renderSetupReport(report: DoctorReport, options: RenderOptions): string {
const { verbose, useColor } = options;
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
const symbol = (s: DoctorStatus) => status(s, STATUS_SYMBOL[s]);
const fails = report.checks.filter((c) => c.status === 'fail');
const lines: string[] = [];
lines.push(bold(report.title));
lines.push('');
lines.push(` No project here yet.`);
lines.push('');
if (fails.length > 0) {
lines.push(` Before you can run ${bold('ktx setup')}, fix this:`);
for (const entry of fails) {
lines.push(` ${symbol('fail')} ${entry.label}: ${entry.detail}`);
if (entry.fix) {
lines.push(` ${dim(`${entry.fix}`)}`);
}
}
lines.push('');
} else {
lines.push(` Run ${bold('ktx setup')} to get started.`);
lines.push('');
}
if (verbose) {
lines.push(dim(' Toolchain:'));
for (const entry of report.checks) {
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
if (entry.fix && entry.status !== 'pass') {
lines.push(` ${dim(`${entry.fix}`)}`);
}
}
lines.push('');
}
return lines.join('\n');
}
function renderPlainReport(report: DoctorReport, options: RenderOptions): string {
if (options.command === 'setup') return renderSetupReport(report, options);
const { verbose, useColor, durationMs, projectName, projectDir } = options;
const dim = (text: string) => styleDim(useColor, text);
const bold = (text: string) => styleBold(useColor, text);
const status = (s: DoctorStatus, text: string) => styleStatus(useColor, s, text);
const symbol = (s: DoctorStatus) => status(s, STATUS_SYMBOL[s]);
const lines: string[] = [];
const titleParts: string[] = [bold(report.title)];
if (projectName) titleParts.push(projectName);
const abbreviatedDir = abbreviateHome(projectDir);
const titleLine = titleParts.join(` ${dim('·')} `);
const dirSuffix = abbreviatedDir ? ` ${dim(`(${abbreviatedDir})`)}` : '';
lines.push(`${titleLine}${dirSuffix}`);
lines.push('');
const groups = new Map<DoctorGroup, DoctorCheck[]>();
for (const entry of report.checks) {
const group = groupOf(entry);
const bucket = groups.get(group) ?? [];
bucket.push(entry);
groups.set(group, bucket);
}
const orderedGroups: DoctorGroup[] = [];
for (const g of GROUP_ORDER) {
if (groups.has(g)) orderedGroups.push(g);
}
for (const g of groups.keys()) {
if (!orderedGroups.includes(g)) orderedGroups.push(g);
}
const labelWidth = orderedGroups.reduce(
(max, g) => Math.max(max, (GROUP_LABEL[g] ?? g).length),
0,
);
for (const group of orderedGroups) {
const entries = groups.get(group) ?? [];
const head = aggregateStatus(entries);
const nonPass = entries.filter((c) => c.status !== 'pass');
const label = (GROUP_LABEL[group] ?? group).padEnd(labelWidth);
if (nonPass.length === 0) {
lines.push(` ${symbol(head)} ${label} ${dim(groupSummaryWhenAllPass(entries))}`);
if (verbose) {
for (const entry of entries) {
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
}
}
continue;
}
if (entries.length === 1) {
const only = entries[0]!;
lines.push(` ${symbol(only.status)} ${label} ${only.detail}`);
if (only.fix) {
lines.push(` ${' '.repeat(2 + labelWidth + 4)}${dim(`${only.fix}`)}`);
}
continue;
}
lines.push(` ${symbol(head)} ${label} ${dim(`${nonPass.length} of ${entries.length} need attention`)}`);
for (const entry of entries) {
if (entry.status === 'pass' && !verbose) continue;
lines.push(` ${symbol(entry.status)} ${entry.label}: ${entry.detail}`);
if (entry.fix) {
lines.push(` ${dim(`${entry.fix}`)}`);
}
}
}
lines.push('');
const totalFail = report.checks.filter((c) => c.status === 'fail').length;
const totalWarn = report.checks.filter((c) => c.status === 'warn').length;
const durationText = durationMs !== undefined ? ` ${dim(`(${(durationMs / 1000).toFixed(2)}s)`)}` : '';
if (totalFail === 0 && totalWarn === 0) {
const hint = ` ${dim('Try:')} ${NEXT_STEPS_PROJECT.join(dim(' · '))}`;
lines.push(`${status('pass', 'Everything ready.')}${hint}${durationText}`);
} else if (totalFail === 0) {
const word = totalWarn === 1 ? 'warning' : 'warnings';
lines.push(
`${status('warn', `${totalWarn} ${word}.`)} ${dim('Run')} ktx status --verbose ${dim('for full details.')}${durationText}`,
);
} else {
const fWord = totalFail === 1 ? 'issue' : 'issues';
const warnSuffix =
totalWarn > 0
? ` ${dim('·')} ${status('warn', `${totalWarn} ${totalWarn === 1 ? 'warning' : 'warnings'}`)}`
: '';
lines.push(
`${status('fail', `${totalFail} ${fWord} to fix.`)}${warnSuffix}${durationText}`,
);
}
lines.push('');
return lines.join('\n');
}
@ -361,12 +441,12 @@ function hasFailures(report: DoctorReport): boolean {
return report.checks.some((item) => item.status === 'fail');
}
function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: KtxDoctorIo): void {
function writeReport(report: DoctorReport, outputMode: KtxDoctorOutputMode, io: KtxDoctorIo, options: RenderOptions): void {
if (outputMode === 'json') {
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return;
}
io.stdout.write(formatDoctorReport(report));
io.stdout.write(renderPlainReport(report, options));
}
export async function runKtxDoctor(
@ -374,18 +454,41 @@ export async function runKtxDoctor(
io: KtxDoctorIo = process,
deps: KtxDoctorDeps = {},
): Promise<number> {
const startedAt = Date.now();
try {
const runSetupChecks = deps.runSetupChecks ?? (() => runSetupDoctorChecks());
const setupChecks = await runSetupChecks();
const report: DoctorReport =
args.command === 'setup'
? { title: 'KTX setup doctor', checks: setupChecks }
: {
title: 'KTX project doctor',
checks: [...setupChecks, ...(await runProjectChecks(args.projectDir, deps))],
};
writeReport(report, args.outputMode, io);
if (args.command === 'project') {
const { loadKtxProject } = await import('@ktx/context/project');
const { buildProjectStatus, renderProjectStatus } = await import('./status-project.js');
const project = await loadKtxProject({ projectDir: args.projectDir });
const projectStatus = buildProjectStatus(project);
const verbose = args.verbose ?? false;
const toolchainChecks = verbose ? await runSetupChecks() : undefined;
if (args.outputMode === 'json') {
io.stdout.write(`${JSON.stringify(projectStatus, null, 2)}\n`);
} else {
io.stdout.write(
renderProjectStatus(projectStatus, {
verbose,
useColor: shouldUseColor(io),
durationMs: Date.now() - startedAt,
toolchainChecks,
}),
);
}
return projectStatus.verdict === 'blocked' ? 1 : 0;
}
const setupChecks = await runSetupChecks();
const report: DoctorReport = { title: 'KTX status', checks: setupChecks };
const renderOptions: RenderOptions = {
verbose: args.verbose ?? false,
useColor: shouldUseColor(io),
durationMs: Date.now() - startedAt,
command: args.command,
};
writeReport(report, args.outputMode, io, renderOptions);
return hasFailures(report) ? 1 : 0;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);

View file

@ -1,202 +0,0 @@
import { buildDefaultKtxProjectConfig, type KtxProjectConnectionConfig } from '@ktx/context/project';
import { HistoricSqlExtensionMissingError } from '@ktx/context/ingest';
import { describe, expect, it, vi } from 'vitest';
import {
runPostgresHistoricSqlDoctorChecks,
type HistoricSqlDoctorProject,
type PostgresHistoricSqlDoctorProbe,
} from './historic-sql-doctor.js';
function projectWithConnections(connections: Record<string, KtxProjectConnectionConfig>): HistoricSqlDoctorProject {
return {
projectDir: '/tmp/ktx-project',
config: {
...buildDefaultKtxProjectConfig('warehouse'),
connections,
ingest: {
...buildDefaultKtxProjectConfig('warehouse').ingest,
adapters: ['live-database', 'historic-sql'],
},
},
};
}
describe('runPostgresHistoricSqlDoctorChecks', () => {
it('passes when no Postgres historic-SQL connections are enabled', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: { driver: 'sqlite', path: './warehouse.db' },
}),
{
postgresHistoricSqlProbe: vi.fn<PostgresHistoricSqlDoctorProbe>(),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres',
label: 'Postgres Historic SQL',
status: 'pass',
detail: 'No enabled Postgres historic-SQL connections',
},
]);
});
it('passes when the PGSS probe succeeds without warnings', async () => {
const probe = vi.fn<PostgresHistoricSqlDoctorProbe>(async () => ({
pgServerVersion: 'PostgreSQL 16.4',
warnings: [],
}));
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{ postgresHistoricSqlProbe: probe },
);
expect(probe).toHaveBeenCalledWith({
projectDir: '/tmp/ktx-project',
connectionId: 'warehouse',
connection: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
env: process.env,
});
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'pass',
detail: 'pg_stat_statements ready (PostgreSQL 16.4)',
},
]);
});
it('passes with an informational note when only pg_stat_statements.max is below the recommended floor', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: async () => ({
pgServerVersion: 'PostgreSQL 16.4',
warnings: [],
info: [
'pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
],
}),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'pass',
detail:
'pg_stat_statements ready (PostgreSQL 16.4); info: pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
},
]);
});
it('warns when pg_stat_statements tracking is disabled', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: async () => ({
pgServerVersion: 'PostgreSQL 16.4',
warnings: [
'pg_stat_statements.track is none; set it to top or all in the Postgres parameter group or config',
],
info: [
'pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
],
}),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'warn',
detail:
'pg_stat_statements ready (PostgreSQL 16.4) with warnings: pg_stat_statements.track is none; set it to top or all in the Postgres parameter group or config; info: pg_stat_statements.max is 1000; set it to at least 5000 to reduce query-template eviction churn',
fix: 'Update the Postgres parameter group or config, then rerun `ktx status --project-dir /tmp/ktx-project`',
},
]);
});
it('fails when a connection has postgres historic SQL but is not a Postgres driver', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'mysql',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: vi.fn<PostgresHistoricSqlDoctorProbe>(),
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'fail',
detail: 'connections.warehouse.historicSql.dialect is postgres but driver is mysql',
fix: 'Set connections.warehouse.driver to postgres or disable historicSql for this connection',
},
]);
});
it('maps PGSS capability errors to actionable failures', async () => {
const checks = await runPostgresHistoricSqlDoctorChecks(
projectWithConnections({
warehouse: {
driver: 'postgres',
url: 'env:WAREHOUSE_DATABASE_URL',
historicSql: { enabled: true, dialect: 'postgres' },
},
}),
{
postgresHistoricSqlProbe: async () => {
throw new HistoricSqlExtensionMissingError({
dialect: 'postgres',
message: 'pg_stat_statements extension is not installed in the connection database.',
remediation: 'Run CREATE EXTENSION pg_stat_statements; against the connection database.',
});
},
},
);
expect(checks).toEqual([
{
id: 'historic-sql-postgres-warehouse',
label: 'Postgres Historic SQL (warehouse)',
status: 'fail',
detail: 'pg_stat_statements extension is not installed in the connection database.',
fix: 'Run CREATE EXTENSION pg_stat_statements; against the connection database.',
},
]);
});
});

View file

@ -1,167 +0,0 @@
import type { KtxProjectConfig, KtxProjectConnectionConfig } from '@ktx/context/project';
import type { DoctorCheck } from './doctor.js';
export interface HistoricSqlDoctorProject {
projectDir: string;
config: Pick<KtxProjectConfig, 'connections' | 'ingest'>;
}
export interface PostgresHistoricSqlDoctorProbeInput {
projectDir: string;
connectionId: string;
connection: KtxProjectConnectionConfig;
env: NodeJS.ProcessEnv;
}
export interface PostgresHistoricSqlDoctorProbeResult {
pgServerVersion: string;
warnings: string[];
info?: string[];
}
export type PostgresHistoricSqlDoctorProbe = (
input: PostgresHistoricSqlDoctorProbeInput,
) => Promise<PostgresHistoricSqlDoctorProbeResult>;
export interface HistoricSqlDoctorDeps {
env?: NodeJS.ProcessEnv;
postgresHistoricSqlProbe?: PostgresHistoricSqlDoctorProbe;
}
function check(status: DoctorCheck['status'], id: string, label: string, detail: string, fix?: string): DoctorCheck {
return fix ? { id, label, status, detail, fix } : { id, label, status, detail };
}
function historicSqlRecord(connection: KtxProjectConnectionConfig): Record<string, unknown> | null {
const historicSql = connection.historicSql;
return historicSql && typeof historicSql === 'object' && !Array.isArray(historicSql)
? (historicSql as Record<string, unknown>)
: null;
}
function isEnabledPostgresHistoricSql(connection: KtxProjectConnectionConfig): boolean {
const historicSql = historicSqlRecord(connection);
return historicSql?.enabled === true && historicSql.dialect === 'postgres';
}
function isPostgresDriver(connection: KtxProjectConnectionConfig): boolean {
const driver = String(connection.driver ?? '').toLowerCase();
return driver === 'postgres' || driver === 'postgresql';
}
function checkId(connectionId: string): string {
return `historic-sql-postgres-${connectionId.replace(/[^a-z0-9_-]+/gi, '-')}`;
}
function capabilityFailureFix(error: unknown, connectionId: string, projectDir: string): string {
if (error instanceof Error && error.name === 'HistoricSqlExtensionMissingError' && 'remediation' in error) {
return String(error.remediation);
}
if (error instanceof Error && error.name === 'HistoricSqlGrantsMissingError' && 'remediation' in error) {
return String(error.remediation);
}
if (error instanceof Error && error.name === 'HistoricSqlVersionUnsupportedError') {
return 'Use PostgreSQL 14 or newer, or disable historicSql for this connection';
}
return `Fix connections.${connectionId} Postgres settings, then rerun \`ktx status --project-dir ${projectDir}\``;
}
function failureDetail(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim().split('\n')[0] ?? error.message.trim();
}
return String(error);
}
function readinessDetail(result: PostgresHistoricSqlDoctorProbeResult): string {
const warningText = result.warnings.length > 0 ? ` with warnings: ${result.warnings.join('; ')}` : '';
const info = result.info ?? [];
const infoText = info.length > 0 ? `; info: ${info.join('; ')}` : '';
return `pg_stat_statements ready (${result.pgServerVersion})${warningText}${infoText}`;
}
async function defaultPostgresHistoricSqlProbe(
input: PostgresHistoricSqlDoctorProbeInput,
): Promise<PostgresHistoricSqlDoctorProbeResult> {
const [{ PostgresPgssReader }, { KtxPostgresHistoricSqlQueryClient, isKtxPostgresConnectionConfig }] =
await Promise.all([import('@ktx/context/ingest'), import('@ktx/connector-postgres')]);
const inputDriver = input.connection.driver ?? 'unknown';
if (!isKtxPostgresConnectionConfig(input.connection)) {
throw new Error(`Native PostgreSQL connector cannot run driver "${inputDriver}"`);
}
const client = new KtxPostgresHistoricSqlQueryClient({
connectionId: input.connectionId,
connection: input.connection,
env: input.env,
});
try {
return await new PostgresPgssReader().probe(client);
} finally {
await client.cleanup();
}
}
export async function runPostgresHistoricSqlDoctorChecks(
project: HistoricSqlDoctorProject,
deps: HistoricSqlDoctorDeps = {},
): Promise<DoctorCheck[]> {
const targets = Object.entries(project.config.connections)
.filter(([, connection]) => isEnabledPostgresHistoricSql(connection))
.sort(([left], [right]) => left.localeCompare(right));
if (targets.length === 0) {
return [
check('pass', 'historic-sql-postgres', 'Postgres Historic SQL', 'No enabled Postgres historic-SQL connections'),
];
}
const probe = deps.postgresHistoricSqlProbe ?? defaultPostgresHistoricSqlProbe;
const env = deps.env ?? process.env;
const checks: DoctorCheck[] = [];
for (const [connectionId, connection] of targets) {
const label = `Postgres Historic SQL (${connectionId})`;
if (!isPostgresDriver(connection)) {
checks.push(
check(
'fail',
checkId(connectionId),
label,
`connections.${connectionId}.historicSql.dialect is postgres but driver is ${String(connection.driver)}`,
`Set connections.${connectionId}.driver to postgres or disable historicSql for this connection`,
),
);
continue;
}
try {
const result = await probe({ projectDir: project.projectDir, connectionId, connection, env });
if (result.warnings.length > 0) {
checks.push(
check(
'warn',
checkId(connectionId),
label,
readinessDetail(result),
`Update the Postgres parameter group or config, then rerun \`ktx status --project-dir ${project.projectDir}\``,
),
);
} else {
checks.push(check('pass', checkId(connectionId), label, readinessDetail(result)));
}
} catch (error) {
checks.push(
check(
'fail',
checkId(connectionId),
label,
failureDetail(error),
capabilityFailureFix(error, connectionId, project.projectDir),
),
);
}
}
return checks;
}

View file

@ -444,20 +444,54 @@ describe('runKtxCli', () => {
expect(io.stderr()).toContain('Choose only one runtime install mode: --yes or --no-input');
});
it('documents setup as a bare command without subcommands', async () => {
it('documents setup with only the common interactive options visible', async () => {
const testIo = makeIo();
await expect(runKtxCli(['setup', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx setup [options]');
expect(testIo.stdout()).not.toContain('Commands:');
expect(testIo.stdout()).not.toContain('setup demo');
expect(testIo.stdout()).not.toContain('setup context');
expect(testIo.stdout()).not.toContain('--skip-llm');
expect(testIo.stdout()).not.toContain('--skip-embeddings');
expect(testIo.stdout()).not.toContain('--embedding-model');
expect(testIo.stdout()).not.toContain('--embedding-dimensions');
expect(testIo.stdout()).not.toContain('--embedding-base-url');
const stdout = testIo.stdout();
expect(stdout).toContain('Usage: ktx setup [options]');
expect(stdout).toContain('--agents');
expect(stdout).toContain('--target <target>');
expect(stdout).toContain('--global');
expect(stdout).toContain('--yes');
expect(stdout).toContain('--no-input');
expect(stdout).toContain('Global Options:');
expect(stdout.match(/--project-dir <path>/g)).toHaveLength(1);
expect(stdout).not.toContain('Commands:');
expect(stdout).not.toContain('setup demo');
expect(stdout).not.toContain('setup context');
for (const hiddenFlag of [
'--new',
'--existing',
'--agent-scope',
'--skip-agents',
'--llm-backend',
'--anthropic-api-key-env',
'--vertex-project',
'--embedding-backend',
'--database ',
'--database-connection-id',
'--new-database-connection-id',
'--enable-historic-sql',
'--historic-sql-min-executions',
'--skip-databases',
'--source ',
'--source-connection-id',
'--metabase-database-id',
'--notion-root-page-id',
'--skip-initial-source-ingest',
'--skip-sources',
'--skip-llm',
'--skip-embeddings',
'--embedding-model',
'--embedding-dimensions',
'--embedding-base-url',
]) {
expect(stdout).not.toContain(hiddenFlag);
}
expect(stdout).not.toMatch(/^ --project\s/m);
expect(testIo.stderr()).toBe('');
});
@ -725,6 +759,23 @@ describe('runKtxCli', () => {
expect(setup).not.toHaveBeenCalled();
});
it('rejects removed setup options', async () => {
const setup = vi.fn(async () => 0);
const cases = [
['setup', '--project'],
['setup', '--agent-scope', 'global'],
['setup', '--skip-initial-source-ingest'],
];
for (const args of cases) {
const testIo = makeIo();
await expect(runKtxCli(['--project-dir', tempDir, ...args], testIo.io, { setup })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown option|error:/i);
}
expect(setup).not.toHaveBeenCalled();
});
it('prints ingest help without invoking ingest execution', async () => {
const testIo = makeIo();
const ingest = vi.fn();
@ -961,7 +1012,7 @@ describe('runKtxCli', () => {
expect(setup).not.toHaveBeenCalled();
expect(doctor).toHaveBeenCalledWith(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled' },
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled', verbose: false },
statusIo.io,
);
expect(statusIo.stderr()).toBe('');
@ -984,7 +1035,7 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['status', '--json', '--no-input'], statusIo.io, { doctor })).resolves.toBe(0);
expect(doctor).toHaveBeenCalledWith(
{ command: 'setup', outputMode: 'json', inputMode: 'disabled' },
{ command: 'setup', outputMode: 'json', inputMode: 'disabled', verbose: false },
statusIo.io,
);
expect(statusIo.stderr()).toBe('');
@ -1250,7 +1301,6 @@ describe('runKtxCli', () => {
'--agents',
'--target',
'codex',
'--project',
'--no-input',
'--yes',
],

View file

@ -1,384 +0,0 @@
/* @jsxImportSource react */
import { render as renderInkTest } from 'ink-testing-library';
import { act, type ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './notion-page-picker-tree.js';
import {
NotionPickerApp,
notionPickerCommandForInkInput,
renderNotionPickerTui,
resolveNotionPickerWidth,
sanitizeNotionPickerTuiError,
windowItems,
windowOffset,
type NotionPickerInkInstance,
type NotionPickerInkRenderOptions,
} from './notion-page-picker-tui.js';
const IDS = {
engineering: '11111111-1111-1111-1111-111111111111',
architecture: '22222222-2222-2222-2222-222222222222',
marketing: '33333333-3333-3333-3333-333333333333',
finance: '44444444-4444-4444-4444-444444444444',
ops: '55555555-5555-5555-5555-555555555555',
sales: '66666666-6666-6666-6666-666666666666',
support: '77777777-7777-7777-7777-777777777777',
product: '88888888-8888-8888-8888-888888888888',
design: '99999999-9999-9999-9999-999999999999',
};
function pages(): NotionPickerPageInput[] {
return [
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
];
}
function manyPages(): NotionPickerPageInput[] {
return [
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
{ id: IDS.finance, title: 'Finance', archived: false, parentId: null },
{ id: IDS.ops, title: 'Operations', archived: false, parentId: null },
{ id: IDS.sales, title: 'Sales', archived: false, parentId: null },
{ id: IDS.support, title: 'Support', archived: false, parentId: null },
{ id: IDS.product, title: 'Product', archived: false, parentId: null },
{ id: IDS.design, title: 'Design', archived: false, parentId: null },
];
}
function state(mode: 'all_accessible' | 'selected_roots' = 'selected_roots') {
return buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: mode,
});
}
async function waitForInkInput(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
}
function fakeInkInstance(): NotionPickerInkInstance {
return {
rerender: vi.fn(),
unmount: vi.fn(),
waitUntilExit: vi.fn(async () => undefined),
};
}
function normalizeFrameWrap(frame: string | undefined): string {
return frame?.replace(/\n/g, ' ') ?? '';
}
afterEach(() => {
vi.useRealTimers();
});
describe('notionPickerCommandForInkInput', () => {
it('maps browse, search, and confirm input to reducer commands', () => {
expect(notionPickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
expect(notionPickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
expect(notionPickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
expect(notionPickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
expect(notionPickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
expect(notionPickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
expect(notionPickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible');
expect(notionPickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
expect(notionPickerCommandForInkInput('s', {}, state().search, null)).toBe('save-request');
expect(notionPickerCommandForInkInput('q', {}, state().search, null)).toBe('quit');
expect(notionPickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
expect(notionPickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
type: 'search-input',
value: 'x',
});
expect(notionPickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe(
'search-backspace',
);
expect(notionPickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
'search-submit',
);
expect(notionPickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
'search-cancel',
);
expect(notionPickerCommandForInkInput('y', {}, state().search, 'mode-switch')).toBe('save-confirm');
expect(notionPickerCommandForInkInput('', { return: true }, state().search, 'mode-switch')).toBe('save-confirm');
expect(notionPickerCommandForInkInput('n', {}, state().search, 'mode-switch')).toBe('save-cancel');
});
});
describe('window helpers', () => {
it('centers the selected row and returns the visible slice', () => {
expect(windowOffset(20, 10, 5)).toBe(8);
expect(windowItems(['a', 'b', 'c', 'd', 'e'], 3, 3)).toEqual({ items: ['c', 'd', 'e'], offset: 2 });
});
it('clamps picker width to the design rule', () => {
expect(resolveNotionPickerWidth(200)).toBe(120);
expect(resolveNotionPickerWidth(100)).toBe(96);
expect(resolveNotionPickerWidth(50)).toBe(60);
expect(resolveNotionPickerWidth(undefined)).toBe(96);
});
});
describe('NotionPickerApp', () => {
it('renders spec banners, row glyphs, search visibility, and hint text', () => {
const initialState = {
...state('all_accessible'),
preLoadWarnings: ['1 stored root_page_ids no longer visible'],
};
const { lastFrame } = renderInkTest(
<NotionPickerApp
initialState={initialState}
connectionId="notion-main"
workspaceLabel="Design Workspace"
cappedAtCount={5000}
currentCrawlMode="all_accessible"
terminalRows={24}
terminalWidth={100}
onExit={vi.fn()}
/>,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('Notion pages visible to integration "Design Workspace"');
expect(frame).toContain('5000-page cap reached - some pages not shown');
expect(frame).toContain('1 stored root_page_ids no longer visible - they will be removed if you save');
expect(frame).toContain('▸ [ ] Engineering Docs ▸ (1)');
expect(frame).toContain(' [ ] Marketing');
expect(frame).not.toContain('Search ready: -');
expect(frame).toContain('space toggle · enter expand · / search · a all · n none · s save & exit · q quit');
});
it('renders partial discovery warnings without stale-root save suffix', () => {
const initialState = {
...state(),
preLoadWarnings: ['Notion search stopped early: rate limit after first page'],
};
const { lastFrame } = renderInkTest(
<NotionPickerApp
initialState={initialState}
connectionId="notion-main"
workspaceLabel="Design Workspace"
cappedAtCount={null}
currentCrawlMode="selected_roots"
terminalRows={24}
terminalWidth={100}
onExit={vi.fn()}
/>,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('Notion search stopped early: rate limit after first page');
expect(frame).not.toContain(
'Notion search stopped early: rate limit after first page - they will be removed if you save',
);
});
it('renders checked parents and locked descendants with the locked design glyphs', () => {
const initialState = {
...state(),
checked: new Set([IDS.engineering]),
expanded: new Set([IDS.engineering]),
};
const { lastFrame } = renderInkTest(
<NotionPickerApp
initialState={initialState}
connectionId="notion-main"
workspaceLabel="Design Workspace"
cappedAtCount={null}
currentCrawlMode="selected_roots"
terminalRows={24}
terminalWidth={100}
onExit={vi.fn()}
/>,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('▸ [×] Engineering Docs ▾');
expect(frame).toContain(' [~] Architecture');
});
it('supports keyboard selection, all_accessible confirmation, and save callback', async () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderInkTest(
<NotionPickerApp
initialState={state('all_accessible')}
connectionId="notion-main"
workspaceLabel="Design Workspace"
cappedAtCount={null}
currentCrawlMode="all_accessible"
terminalRows={24}
terminalWidth={100}
onExit={onExit}
/>,
);
stdin.write(' ');
await waitForInkInput();
expect(lastFrame()).toContain('[×] Engineering Docs');
stdin.write('s');
await waitForInkInput();
expect(normalizeFrameWrap(lastFrame())).toContain(
'Save will switch crawl_mode all_accessible -> selected_roots and limit ingest to 1 selected page. [y] confirm [esc] back',
);
stdin.write('y');
await waitForInkInput();
expect(onExit).toHaveBeenCalledWith({ kind: 'save', rootPageIds: [IDS.engineering] });
});
it('removes transient hints after their expiry time', async () => {
vi.useFakeTimers();
const onExit = vi.fn();
const { stdin, lastFrame } = renderInkTest(
<NotionPickerApp
initialState={state()}
connectionId="notion-main"
workspaceLabel="Design Workspace"
cappedAtCount={null}
currentCrawlMode="selected_roots"
terminalRows={24}
terminalWidth={100}
onExit={onExit}
/>,
);
await act(async () => {
stdin.write('s');
await vi.advanceTimersByTimeAsync(10);
});
expect(lastFrame()).toContain('Select at least one page or press q to quit');
await act(async () => {
await vi.advanceTimersByTimeAsync(2500);
});
expect(lastFrame()).not.toContain('Select at least one page or press q to quit');
expect(onExit).not.toHaveBeenCalled();
});
it('renders row-window overflow indicators when the visible list is clipped', async () => {
const onExit = vi.fn();
const initialState = buildInitialState({
tree: buildPickerTree(manyPages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
});
initialState.expanded = new Set([IDS.engineering]);
const { stdin, lastFrame } = renderInkTest(
<NotionPickerApp
initialState={initialState}
connectionId="notion-main"
workspaceLabel="Design Workspace"
cappedAtCount={null}
currentCrawlMode="selected_roots"
terminalRows={13}
terminalWidth={100}
onExit={onExit}
/>,
);
expect(lastFrame()).toContain('↓ 4 more');
stdin.write('\u001B[B');
stdin.write('\u001B[B');
stdin.write('\u001B[B');
stdin.write('\u001B[B');
await waitForInkInput();
const frame = lastFrame() ?? '';
expect(frame).toContain('↑ ');
expect(frame).toContain('↓ ');
expect(onExit).not.toHaveBeenCalled();
});
it('returns quit without saving', async () => {
const onExit = vi.fn();
const { stdin } = renderInkTest(
<NotionPickerApp
initialState={state()}
connectionId="notion-main"
workspaceLabel="Design Workspace"
cappedAtCount={null}
currentCrawlMode="selected_roots"
terminalRows={24}
terminalWidth={100}
onExit={onExit}
/>,
);
stdin.write('q');
await waitForInkInput();
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
});
});
describe('renderNotionPickerTui', () => {
it('returns the app result from the Ink runtime', async () => {
const io = {
stdin: { isTTY: true, setRawMode: vi.fn() },
stdout: { isTTY: true, columns: 100, rows: 24, write: vi.fn() },
stderr: { write: vi.fn() },
};
const renderInk = vi.fn((_tree: ReactNode, _options: NotionPickerInkRenderOptions) => fakeInkInstance());
await expect(
renderNotionPickerTui(
{
initialState: state(),
connectionId: 'notion-main',
workspaceLabel: 'Design Workspace',
cappedAtCount: null,
currentCrawlMode: 'selected_roots',
},
io,
{ renderInk },
),
).resolves.toEqual({ kind: 'quit' });
expect(renderInk).toHaveBeenCalledOnce();
});
it('sanitizes render errors and tells the user to use no-input mode', async () => {
expect(sanitizeNotionPickerTuiError(new Error('token=secret https://api.notion.com/v1/search'))).toBe(
'[redacted] [redacted-url]',
);
});
it('falls back to quit with a scripted-mode hint when Ink cannot initialize', async () => {
let stderr = '';
const io = {
stdin: { isTTY: false, setRawMode: vi.fn() },
stdout: { isTTY: false, columns: 100, rows: 24, write: vi.fn() },
stderr: {
write(chunk: string) {
stderr += chunk;
},
},
};
await expect(
renderNotionPickerTui(
{
initialState: state(),
connectionId: 'notion-main',
workspaceLabel: 'Design Workspace',
cappedAtCount: null,
currentCrawlMode: 'selected_roots',
},
io,
{
renderInk: vi.fn(() => {
throw new Error('token=secret');
}),
},
),
).resolves.toEqual({ kind: 'quit' });
expect(stderr).toContain('Use --no-input --notion-root-page-id <UUID> for scripted mode');
expect(stderr).not.toContain('secret');
});
});

View file

@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { PickerState } from './tree-picker-state.js';
import type { TreePickerChrome, TreePickerResult, TreePickerTuiIo } from './tree-picker-tui.js';
import {
discoverNotionPickerPages,
notionPickerPageFromSearchResult,
@ -6,8 +8,6 @@ import {
pickNotionRootPages,
resolveNotionWorkspaceLabel,
type NotionPickerApi,
type PickerRenderInput,
type PickerRenderResult,
} from './notion-page-picker.js';
function makeIo() {
@ -162,20 +162,27 @@ describe('Notion page picker helpers', () => {
});
});
type RenderPickerArgs = [TreePickerChrome, PickerState, TreePickerTuiIo];
describe('pickNotionRootPages', () => {
it('discovers visible pages, warns about stale roots, renders the TUI, and returns selected roots', async () => {
const api = fakeNotionApi([
notionPage(PAGE_IDS.engineering, 'Engineering'),
notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering),
]);
const renderPicker = vi.fn(async (input: PickerRenderInput): Promise<PickerRenderResult> => {
expect(input.connectionId).toBe('notion-main');
expect(input.workspaceLabel).toBe('Design Workspace');
expect(input.currentCrawlMode).toBe('all_accessible');
expect(input.cappedAtCount).toBeNull();
expect(input.initialState.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
return { kind: 'save', rootPageIds: [PAGE_IDS.engineering] };
});
const renderPicker = vi.fn(
async (chrome: TreePickerChrome, state: PickerState): Promise<TreePickerResult> => {
expect(chrome.title).toBe('Select Notion pages to ingest');
expect(chrome.subtitleLines).toEqual(['Workspace: Design Workspace']);
expect(chrome.warningLines ?? []).toEqual([]);
expect(chrome.confirmSaveMessage).toBeTypeOf('function');
expect(state.requireConfirmOnSave).toBe(true);
expect(state.preLoadWarnings).toEqual([
'1 stored root_page_ids no longer visible - they will be removed if you save',
]);
return { kind: 'save', selectedIds: [PAGE_IDS.engineering] };
},
);
const io = makeIo();
await expect(
@ -223,7 +230,7 @@ describe('pickNotionRootPages', () => {
makeIo().io,
{
createNotionApi,
renderPicker: vi.fn(async (): Promise<PickerRenderResult> => ({ kind: 'quit' })),
renderPicker: vi.fn(async (): Promise<TreePickerResult> => ({ kind: 'quit' })),
},
),
).resolves.toEqual({ kind: 'back' });
@ -243,11 +250,13 @@ describe('pickNotionRootPages', () => {
.mockRejectedValueOnce(new Error('rate limit after first page')),
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot', bot: { workspace_name: 'Design Workspace' } })),
};
let renderInput: PickerRenderInput | undefined;
const renderPicker = vi.fn(async (input: PickerRenderInput): Promise<PickerRenderResult> => {
renderInput = input;
return { kind: 'quit' };
});
let captured: RenderPickerArgs | undefined;
const renderPicker = vi.fn(
async (chrome: TreePickerChrome, state: PickerState, io: TreePickerTuiIo): Promise<TreePickerResult> => {
captured = [chrome, state, io];
return { kind: 'quit' };
},
);
const io = makeIo();
await expect(
@ -271,11 +280,12 @@ describe('pickNotionRootPages', () => {
).resolves.toEqual({ kind: 'back' });
expect(renderPicker).toHaveBeenCalledOnce();
if (!renderInput) {
if (!captured) {
throw new Error('renderPicker was not called');
}
expect(renderInput.initialState.preLoadWarnings).toEqual(['Notion search stopped early: rate limit after first page']);
expect(renderInput.initialState.tree.map((node) => node.title)).toEqual(['Engineering']);
const [, state] = captured;
expect(state.preLoadWarnings).toEqual(['Notion search stopped early: rate limit after first page']);
expect(state.tree.map((node) => node.title)).toEqual(['Engineering']);
expect(io.stderr()).toContain('Notion search stopped early: rate limit after first page');
});
@ -300,7 +310,7 @@ describe('pickNotionRootPages', () => {
}),
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot' })),
})),
renderPicker: vi.fn(async (): Promise<PickerRenderResult> => ({ kind: 'quit' })),
renderPicker: vi.fn(async (): Promise<TreePickerResult> => ({ kind: 'quit' })),
},
),
).resolves.toEqual({ kind: 'unavailable', message: 'Notion API unavailable' });

View file

@ -3,13 +3,19 @@ import { type NotionApi, type NotionBotInfo, NotionClient } from '@ktx/context/i
import type { KtxProjectConnectionConfig } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { profileMark } from './startup-profile.js';
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './notion-page-picker-tree.js';
import {
type NotionPickerTuiIo,
type PickerRenderInput,
type PickerRenderResult,
renderNotionPickerTui,
} from './notion-page-picker-tui.js';
buildInitialState,
buildPickerTree,
flattenSelection,
type PickerState,
type TreePickerNodeInput,
} from './tree-picker-state.js';
import {
renderTreePickerTui,
type TreePickerChrome,
type TreePickerResult,
type TreePickerTuiIo,
} from './tree-picker-tui.js';
profileMark('module:notion-page-picker');
@ -19,8 +25,6 @@ export interface PickNotionRootPagesArgs {
}
export type NotionPickerApi = Pick<NotionApi, 'search' | 'retrieveBotUser'>;
export type { PickerRenderInput, PickerRenderResult };
export type NotionRootPagePickResult =
| { kind: 'selected'; rootPageIds: string[] }
| { kind: 'back' }
@ -29,10 +33,16 @@ export type NotionRootPagePickResult =
export interface NotionRootPagePickerDeps {
env?: Record<string, string | undefined>;
createNotionApi?: (authToken: string) => NotionPickerApi;
renderPicker?: (input: PickerRenderInput, io: NotionPickerTuiIo) => Promise<PickerRenderResult>;
renderPicker?: (
chrome: TreePickerChrome,
initialState: PickerState,
io: TreePickerTuiIo,
) => Promise<TreePickerResult>;
}
const NOTION_PICKER_PAGE_CAP = 5000;
const NOTION_SCRIPTED_MODE_HINT =
'Notion picker requires a TTY. Use --no-input --notion-root-page-id <UUID> for scripted mode.';
function assertSafeNotionPickerConnectionId(connectionId: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
@ -50,6 +60,14 @@ export function normalizeNotionPageId(value: string): string {
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
}
function tryNormalizeNotionPageId(value: string): string | null {
try {
return normalizeNotionPageId(value);
} catch {
return null;
}
}
function recordValue(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
@ -88,7 +106,7 @@ function extractParentPageId(page: Record<string, unknown>): string | null {
return normalizeNotionPageId(parent.page_id);
}
export function notionPickerPageFromSearchResult(result: Record<string, unknown>): NotionPickerPageInput {
export function notionPickerPageFromSearchResult(result: Record<string, unknown>): TreePickerNodeInput {
const id = typeof result.id === 'string' ? normalizeNotionPageId(result.id) : '';
if (!id) {
throw new Error('Notion page search result is missing id');
@ -104,9 +122,9 @@ export function notionPickerPageFromSearchResult(result: Record<string, unknown>
export async function discoverNotionPickerPages(
api: NotionPickerApi,
options: { cap?: number } = {},
): Promise<{ pages: NotionPickerPageInput[]; cappedAtCount: number | null; warnings: string[] }> {
): Promise<{ pages: TreePickerNodeInput[]; cappedAtCount: number | null; warnings: string[] }> {
const cap = options.cap ?? NOTION_PICKER_PAGE_CAP;
const pages: NotionPickerPageInput[] = [];
const pages: TreePickerNodeInput[] = [];
const warnings: string[] = [];
let cursor: string | null | undefined = null;
@ -171,6 +189,33 @@ function notionCrawlMode(connection: KtxProjectConnectionConfig): 'all_accessibl
return connection.crawl_mode === 'all_accessible' ? 'all_accessible' : 'selected_roots';
}
function selectedPageCountText(count: number): string {
return `${count} selected ${count === 1 ? 'page' : 'pages'}`;
}
function notionChrome(args: {
workspaceLabel: string;
cappedAtCount: number | null;
currentCrawlMode: 'all_accessible' | 'selected_roots';
}): TreePickerChrome {
const warningLines: string[] = [];
if (args.cappedAtCount) {
warningLines.push(`${args.cappedAtCount}-page cap reached - some pages not shown`);
}
return {
title: 'Select Notion pages to ingest',
subtitleLines: [`Workspace: ${args.workspaceLabel}`],
warningLines,
confirmSaveMessage:
args.currentCrawlMode === 'all_accessible'
? (state) =>
`Switch crawl_mode from all_accessible to selected_roots? Will limit ingest to ${selectedPageCountText(
flattenSelection(state.checked, state.byId).length,
)}. Press Enter to confirm or Escape to go back.`
: undefined,
};
}
export async function pickNotionRootPages(
args: PickNotionRootPagesArgs,
io: KtxCliIo = process,
@ -190,10 +235,14 @@ export async function pickNotionRootPages(
const api = deps.createNotionApi ? deps.createNotionApi(authToken) : new NotionClient(authToken);
const discovery = await discoverNotionPickerPages(api);
const tree = buildPickerTree(discovery.pages);
const normalizedExistingIds = stringArray(args.connection.root_page_ids)
.map((raw) => tryNormalizeNotionPageId(raw))
.filter((id): id is string => id !== null);
const initialState = buildInitialState({
tree,
existingRootPageIds: stringArray(args.connection.root_page_ids),
currentCrawlMode: crawlMode,
existingSelectedIds: normalizedExistingIds,
requireConfirmOnSave: crawlMode === 'all_accessible',
staleWarning: (count) => `${count} stored root_page_ids no longer visible - they will be removed if you save`,
});
const preLoadWarnings = [...discovery.warnings, ...initialState.preLoadWarnings];
const renderState =
@ -207,23 +256,25 @@ export async function pickNotionRootPages(
io.stderr.write(`${warning}\n`);
}
const workspaceLabel = await resolveNotionWorkspaceLabel(api, args.connectionId);
const result = await (deps.renderPicker ?? renderNotionPickerTui)(
{
initialState: renderState,
connectionId: args.connectionId,
workspaceLabel,
cappedAtCount: discovery.cappedAtCount,
currentCrawlMode: crawlMode,
},
io as NotionPickerTuiIo,
);
const chrome = notionChrome({
workspaceLabel,
cappedAtCount: discovery.cappedAtCount,
currentCrawlMode: crawlMode,
});
const renderPicker =
deps.renderPicker ??
((chromeArg, state, ioArg) =>
renderTreePickerTui({ chrome: chromeArg, initialState: state }, ioArg, {
scriptedModeHint: NOTION_SCRIPTED_MODE_HINT,
}));
const result = await renderPicker(chrome, renderState, io as TreePickerTuiIo);
if (result.kind === 'quit') {
return { kind: 'back' };
}
if (result.rootPageIds.length === 0) {
if (result.selectedIds.length === 0) {
return { kind: 'unavailable', message: 'Notion picker did not return any selected pages.' };
}
return { kind: 'selected', rootPageIds: result.rootPageIds };
return { kind: 'selected', rootPageIds: result.selectedIds };
} catch (error) {
return { kind: 'unavailable', message: error instanceof Error ? error.message : String(error) };
}

View file

@ -82,7 +82,7 @@ export function plannedKtxAgentFiles(input: {
{ kind: 'file', path: join(codexHome, 'instructions/ktx.md'), role: 'rule' as const },
];
}
throw new Error(`Global ${input.target} installation is not supported; use --project.`);
throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
}
const root = resolve(input.projectDir);

View file

@ -5,10 +5,15 @@ import { initKtxProject, parseKtxProjectConfig, readKtxSetupState, writeKtxSetup
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type KtxSetupDatabaseDriver,
type KtxSetupDatabasesDeps,
type KtxSetupDatabasesPromptAdapter,
runKtxSetupDatabasesStep,
} from './setup-databases.js';
import type { KtxCliIo } from './cli-runtime.js';
import type {
DatabaseScopePickResult,
PickDatabaseScopeArgs,
} from './database-tree-picker.js';
function makeIo() {
let stdout = '';
@ -32,6 +37,43 @@ function makeIo() {
};
}
type ScopePick =
| 'back'
| 'enable-all'
| { schemas: string[]; tables: string[] };
interface PickerStubs {
pickDatabaseScope: KtxSetupDatabasesDeps['pickDatabaseScope'];
scopeCalls: PickDatabaseScopeArgs[];
}
function makePickerStubs(options: { scopes?: ScopePick[] } = {}): PickerStubs {
const queue: ScopePick[] = [...(options.scopes ?? [])];
const scopeCalls: PickDatabaseScopeArgs[] = [];
return {
scopeCalls,
pickDatabaseScope: vi.fn(async (args: PickDatabaseScopeArgs): Promise<DatabaseScopePickResult> => {
scopeCalls.push(args);
const next = queue.shift();
if (next === undefined || next === 'enable-all') {
const enabledTables = args.discovered.map((t) => `${t.schema}.${t.name}`);
const activeSchemas = args.supportsSchemaScope
? Array.from(new Set(args.discovered.map((t) => t.schema)))
: [];
return { kind: 'selected', activeSchemas, enabledTables };
}
if (next === 'back') {
return { kind: 'back' };
}
return {
kind: 'selected',
activeSchemas: args.supportsSchemaScope ? next.schemas : [],
enabledTables: next.tables,
};
}),
};
}
function makePromptAdapter(options: {
multiselectValues?: string[][];
selectValues?: string[];
@ -819,7 +861,6 @@ describe('setup databases step', () => {
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
const prompts = makePromptAdapter({
textValues: ['env:DATABASE_URL'],
multiselectValues: [['analytics']],
});
let primaryMenuCount = 0;
vi.mocked(prompts.select).mockImplementation(async (options) => {
@ -835,11 +876,21 @@ describe('setup databases step', () => {
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['analytics', 'public']);
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]);
const pickers = makePickerStubs({
scopes: [{ schemas: ['analytics'], tables: ['analytics.customers'] }],
});
const result = await runKtxSetupDatabasesStep(
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
makeIo().io,
{ prompts, testConnection, scanConnection, listSchemas, listTables },
{
prompts,
testConnection,
scanConnection,
listSchemas,
listTables,
pickDatabaseScope: pickers.pickDatabaseScope,
},
);
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
@ -848,7 +899,7 @@ describe('setup databases step', () => {
placeholder: 'env:DATABASE_URL',
initialValue: 'env:DATABASE_URL',
});
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse');
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['analytics', 'public']);
expect(testConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
expect(scanConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
@ -882,7 +933,6 @@ describe('setup databases step', () => {
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
const prompts = makePromptAdapter({
textValues: ['env:DATABASE_URL'],
multiselectValues: [['public'], ['public.customers', 'public.orders']],
});
let primaryMenuCount = 0;
vi.mocked(prompts.select).mockImplementation(async (options) => {
@ -892,7 +942,6 @@ describe('setup databases step', () => {
}
if (options.message === 'Primary source to edit') return 'warehouse';
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
if (options.message.startsWith('Tables found in selected schemas')) return 'customize';
return 'back';
});
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
@ -901,6 +950,9 @@ describe('setup databases step', () => {
{ schema: 'public', name: 'orders', kind: 'table' as const },
{ schema: 'public', name: 'products', kind: 'table' as const },
]);
const pickers = makePickerStubs({
scopes: [{ schemas: ['public'], tables: ['public.customers', 'public.orders'] }],
});
const result = await runKtxSetupDatabasesStep(
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
@ -911,29 +963,17 @@ describe('setup databases step', () => {
scanConnection: vi.fn(async () => 0),
listSchemas,
listTables,
pickDatabaseScope: pickers.pickDatabaseScope,
},
);
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
expect(prompts.multiselect).toHaveBeenNthCalledWith(1, {
message: expect.stringContaining('PostgreSQL schemas to scan'),
options: [
{ value: 'orbit_analytics', label: 'orbit_analytics' },
{ value: 'orbit_raw', label: 'orbit_raw' },
{ value: 'public', label: 'public' },
],
initialValues: ['public'],
required: true,
});
expect(prompts.multiselect).toHaveBeenNthCalledWith(2, {
message: expect.stringContaining('Tables to enable for warehouse'),
options: [
{ value: 'public.customers', label: 'public.customers' },
{ value: 'public.orders', label: 'public.orders' },
{ value: 'public.products', label: 'public.products' },
],
initialValues: ['public.customers', 'public.orders'],
required: true,
expect(pickers.scopeCalls).toHaveLength(1);
expect(pickers.scopeCalls[0]).toMatchObject({
connectionId: 'warehouse',
schemaNoun: 'schema',
supportsSchemaScope: true,
existing: { enabledTables: ['public.customers', 'public.orders'] },
});
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections.warehouse).toMatchObject({
@ -965,7 +1005,6 @@ describe('setup databases step', () => {
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
const prompts = makePromptAdapter({
textValues: ['env:DATABASE_URL'],
multiselectValues: [['back']],
});
let primaryMenuCount = 0;
vi.mocked(prompts.select).mockImplementation(async (options) => {
@ -980,19 +1019,29 @@ describe('setup databases step', () => {
const testConnection = vi.fn(async () => 0);
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['analytics', 'public']);
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]);
const listTables = vi.fn(async () => [
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
{ schema: 'public', name: 'orders', kind: 'table' as const },
]);
const pickers = makePickerStubs({ scopes: ['back'] });
const result = await runKtxSetupDatabasesStep(
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
makeIo().io,
{ prompts, testConnection, scanConnection, listSchemas, listTables },
{
prompts,
testConnection,
scanConnection,
listSchemas,
listTables,
pickDatabaseScope: pickers.pickDatabaseScope,
},
);
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
expect(primaryMenuCount).toBe(2);
expect(testConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
expect(scanConnection).not.toHaveBeenCalled();
expect(listTables).not.toHaveBeenCalled();
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections.warehouse).toMatchObject({
url: 'env:DATABASE_URL',
@ -1031,7 +1080,6 @@ describe('setup databases step', () => {
}
if (options.message === 'Primary source to edit') return 'warehouse';
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
if (options.message.startsWith('Tables found in selected schemas')) return 'back';
return 'back';
});
const testConnection = vi.fn(async () => 0);
@ -1041,16 +1089,24 @@ describe('setup databases step', () => {
{ schema: 'public', name: 'customers', kind: 'table' as const },
{ schema: 'public', name: 'orders', kind: 'table' as const },
]);
const pickers = makePickerStubs({ scopes: ['back'] });
const result = await runKtxSetupDatabasesStep(
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
makeIo().io,
{ prompts, testConnection, scanConnection, listSchemas, listTables },
{
prompts,
testConnection,
scanConnection,
listSchemas,
listTables,
pickDatabaseScope: pickers.pickDatabaseScope,
},
);
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
expect(primaryMenuCount).toBe(2);
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse');
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['public']);
expect(scanConnection).not.toHaveBeenCalled();
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections.warehouse).toMatchObject({
@ -1083,19 +1139,18 @@ describe('setup databases step', () => {
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
const prompts = makePromptAdapter({
textValues: ['env:DATABASE_URL'],
multiselectValues: [['public']],
});
vi.mocked(prompts.select).mockImplementation(async (options) => {
if (options.message === 'Primary sources already configured: warehouse\nWhat would you like to do?') return 'edit';
if (options.message === 'Primary source to edit') return 'warehouse';
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
if (options.message.startsWith('Tables found in selected schemas')) return 'all';
return 'back';
});
const listTables = vi.fn(async () => [
{ schema: 'public', name: 'customers', kind: 'table' as const },
{ schema: 'public', name: 'orders', kind: 'table' as const },
]);
const pickers = makePickerStubs({ scopes: ['enable-all'] });
const result = await runKtxSetupDatabasesStep(
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
@ -1105,6 +1160,7 @@ describe('setup databases step', () => {
testConnection: vi.fn(async () => 0),
scanConnection: vi.fn(async () => 1),
listTables,
pickDatabaseScope: pickers.pickDatabaseScope,
},
);
@ -1390,7 +1446,6 @@ describe('setup databases step', () => {
const prompts = makePromptAdapter({
selectValues: ['url'],
textValues: ['', 'env:DATABASE_URL'],
multiselectValues: [['orbit_analytics', 'orbit_raw']],
});
const testConnection = vi.fn(async () => 0);
const scanConnection = vi.fn(async asyncScanProjectDir => {
@ -1401,6 +1456,19 @@ describe('setup databases step', () => {
return 0;
});
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
const listTables = vi.fn(async () => [
{ schema: 'orbit_analytics', name: 'events', kind: 'table' as const },
{ schema: 'orbit_raw', name: 'inputs', kind: 'table' as const },
{ schema: 'public', name: 'misc', kind: 'table' as const },
]);
const pickers = makePickerStubs({
scopes: [
{
schemas: ['orbit_analytics', 'orbit_raw'],
tables: ['orbit_analytics.events', 'orbit_raw.inputs'],
},
],
});
const result = await runKtxSetupDatabasesStep(
{
@ -1411,20 +1479,24 @@ describe('setup databases step', () => {
skipDatabases: false,
},
io.io,
{ prompts, testConnection, scanConnection, listSchemas },
{
prompts,
testConnection,
scanConnection,
listSchemas,
listTables,
pickDatabaseScope: pickers.pickDatabaseScope,
},
);
expect(result.status).toBe('ready');
expect(listSchemas).toHaveBeenCalledWith(tempDir, 'postgres-warehouse');
expect(prompts.multiselect).toHaveBeenCalledWith({
message: expect.stringContaining('PostgreSQL schemas to scan'),
options: [
{ value: 'orbit_analytics', label: 'orbit_analytics' },
{ value: 'orbit_raw', label: 'orbit_raw' },
{ value: 'public', label: 'public' },
],
initialValues: ['orbit_analytics', 'orbit_raw'],
required: true,
expect(pickers.scopeCalls).toHaveLength(1);
expect(pickers.scopeCalls[0]).toMatchObject({
connectionId: 'postgres-warehouse',
schemaNoun: 'schema',
schemaNounPlural: 'schemas',
defaultSchemas: ['orbit_analytics', 'orbit_raw'],
});
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections['postgres-warehouse']).toMatchObject({

View file

@ -14,6 +14,11 @@ import {
import type { KtxTableListEntry } from '@ktx/context/scan';
import type { KtxCliIo } from './cli-runtime.js';
import { runKtxConnection } from './connection.js';
import {
pickDatabaseScope as defaultPickDatabaseScope,
type DatabaseScopePickResult,
type PickDatabaseScopeArgs,
} from './database-tree-picker.js';
import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
import { runKtxScan } from './scan.js';
import { writeProjectLocalSecretReference } from './setup-secrets.js';
@ -90,7 +95,8 @@ export interface KtxSetupDatabasesDeps {
scanConnection?: (projectDir: string, connectionId: string, io: KtxCliIo) => Promise<number>;
rebuildNativeSqlite?: (io: KtxCliIo) => Promise<number>;
listSchemas?: (projectDir: string, connectionId: string) => Promise<string[]>;
listTables?: (projectDir: string, connectionId: string) => Promise<KtxTableListEntry[]>;
listTables?: (projectDir: string, connectionId: string, schemas?: string[]) => Promise<KtxTableListEntry[]>;
pickDatabaseScope?: (args: PickDatabaseScopeArgs, io: KtxCliIo) => Promise<DatabaseScopePickResult>;
historicSqlProbe?: KtxSetupHistoricSqlProbe;
}
@ -363,11 +369,15 @@ function configuredSchemas(connection: KtxProjectConnectionConfig | undefined, d
return values.length > 0 ? values : undefined;
}
async function defaultListTables(projectDir: string, connectionId: string): Promise<KtxTableListEntry[]> {
async function defaultListTables(
projectDir: string,
connectionId: string,
schemasOverride?: string[],
): Promise<KtxTableListEntry[]> {
const project = await loadKtxProject({ projectDir });
const connection = project.config.connections[connectionId];
const driver = normalizeDriver(connection?.driver);
const schemas = driver ? configuredSchemas(connection, driver) : undefined;
const schemas = schemasOverride ?? (driver ? configuredSchemas(connection, driver) : undefined);
if (driver === 'postgres') {
const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('@ktx/connector-postgres');
@ -1271,145 +1281,98 @@ async function writeScopeConfig(input: {
});
}
async function clearScopeConfig(projectDir: string, connectionId: string): Promise<void> {
const project = await loadKtxProject({ projectDir });
const connection = project.config.connections[connectionId];
if (!connection) return;
const driver = normalizeDriver(connection.driver);
if (!driver) return;
const spec = SCOPE_DISCOVERY_SPECS[driver];
if (!spec) return;
const cleaned = Object.fromEntries(
Object.entries(connection).filter(
([key]) => key !== spec.configArrayField && key !== spec.configSingleField && key !== 'enabled_tables',
),
) as KtxProjectConnectionConfig;
await writeConnectionConfig({ projectDir, connectionId, connection: cleaned });
}
async function maybeConfigureSchemaScope(input: {
async function maybeConfigureDatabaseScope(input: {
projectDir: string;
connectionId: string;
args: KtxSetupDatabasesArgs;
prompts: KtxSetupDatabasesPromptAdapter;
deps: KtxSetupDatabasesDeps;
io: KtxCliIo;
forcePrompt?: boolean;
}): Promise<ConnectionSetupStatus> {
const project = await loadKtxProject({ projectDir: input.projectDir });
const connection = project.config.connections[input.connectionId];
const driver = normalizeDriver(connection?.driver);
if (!driver) return 'ready';
const spec = SCOPE_DISCOVERY_SPECS[driver];
if (!spec) return 'ready';
const arrayVal = connection?.[spec.configArrayField];
if (Array.isArray(arrayVal) && arrayVal.length > 0 && input.forcePrompt !== true) {
return 'ready';
}
if (input.args.databaseSchemas.length > 0) {
await writeScopeConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
values: input.args.databaseSchemas,
spec,
});
return 'ready';
}
writeSetupSection(input.io, `Discovering ${spec.promptLabel.toLowerCase()}`, [
`Connecting to ${input.connectionId}`,
]);
let discovered: string[];
try {
discovered = unique(
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
input.io.stderr.write(
input.forcePrompt === true
? `Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; edit was not saved. ` +
`Pass --database-schema to set it explicitly. ${detail}\n`
: `Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; continuing with existing ${spec.noun} scope. ` +
`Pass --database-schema to set it explicitly. ${detail}\n`,
);
return input.forcePrompt === true ? 'failed' : 'ready';
}
if (discovered.length === 0) {
return 'ready';
}
let selected: string[];
if (input.args.inputMode === 'disabled' || discovered.length === 1) {
const preconfigured = configuredScopeValues(connection, spec).filter((v) => discovered.includes(v));
selected = preconfigured.length > 0 ? preconfigured : discovered;
} else {
const preconfigured = configuredScopeValues(connection, spec).filter((v) => discovered.includes(v));
const initialValues = preconfigured.length > 0 ? preconfigured : spec.defaultSelection(discovered);
const choices = await input.prompts.multiselect({
message: withMultiselectNavigation(
`${spec.promptLabel} to scan\n` +
`KTX found multiple ${spec.nounPlural}. Select every ${spec.noun} agents should use.`,
),
options: discovered.map((v) => ({ value: v, label: v })),
initialValues,
required: true,
});
if (choices.includes('back')) {
return 'back';
}
selected = choices.length > 0 ? choices : initialValues;
}
await writeScopeConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
values: selected,
spec,
});
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
`${selected.join(', ')}`,
]);
return 'ready';
}
async function maybeConfigureTableScope(input: {
projectDir: string;
connectionId: string;
args: KtxSetupDatabasesArgs;
prompts: KtxSetupDatabasesPromptAdapter;
io: KtxCliIo;
deps: KtxSetupDatabasesDeps;
forcePrompt?: boolean;
}): Promise<ConnectionSetupStatus> {
const project = await loadKtxProject({ projectDir: input.projectDir });
const connection = project.config.connections[input.connectionId];
const driver = normalizeDriver(connection?.driver);
if (!driver || driver === 'sqlite') return 'ready';
const spec = SCOPE_DISCOVERY_SPECS[driver];
const existingTables = connection?.enabled_tables;
if (Array.isArray(existingTables) && existingTables.length > 0 && input.forcePrompt !== true) {
const hasExistingTables = Array.isArray(existingTables) && existingTables.length > 0;
const existingScope = spec ? configuredScopeValues(connection, spec) : [];
const hasExistingScope = !spec || existingScope.length > 0;
if (hasExistingTables && hasExistingScope && input.forcePrompt !== true) {
return 'ready';
}
const cliSchemas = input.args.databaseSchemas;
if (input.args.inputMode === 'disabled') {
if (spec) {
let scopeToWrite: string[] = cliSchemas;
if (scopeToWrite.length === 0) {
try {
scopeToWrite = unique(
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
input.io.stderr.write(
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
);
return 'ready';
}
}
if (scopeToWrite.length > 0) {
await writeScopeConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
values: scopeToWrite,
spec,
});
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
`${scopeToWrite.join(', ')}`,
]);
}
}
return 'ready';
}
if (spec && cliSchemas.length > 0) {
await writeScopeConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
values: cliSchemas,
spec,
});
}
writeSetupSection(input.io, 'Discovering tables', [
`Connecting to ${input.connectionId}`,
]);
const schemasFilter = await (async (): Promise<string[]> => {
if (cliSchemas.length > 0) return cliSchemas;
if (!spec) return [];
try {
return unique(
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
input.io.stderr.write(
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
);
return [];
}
})();
let discovered: KtxTableListEntry[];
try {
discovered = await (input.deps.listTables ?? defaultListTables)(
input.projectDir,
input.connectionId,
schemasFilter.length > 0 ? schemasFilter : undefined,
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@ -1429,84 +1392,72 @@ async function maybeConfigureTableScope(input: {
}
const allQualified = discovered.map((t) => `${t.schema}.${t.name}`);
const schemasInDiscovery = unique(discovered.map((t) => t.schema));
const defaultSchemas = (() => {
if (cliSchemas.length > 0) return cliSchemas;
if (!spec) return schemasInDiscovery;
return spec.defaultSelection(schemasInDiscovery);
})();
const existingEnabled =
hasExistingTables && input.forcePrompt === true
? (existingTables ?? []).filter(
(table): table is string => typeof table === 'string' && allQualified.includes(table),
)
: [];
let activeSchemas: string[];
let enabledTables: string[];
if (discovered.length === 1) {
await writeConnectionConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
connection: { ...connection!, enabled_tables: allQualified },
});
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
`${allQualified[0]}`,
]);
return 'ready';
}
const bySchema = new Map<string, KtxTableListEntry[]>();
for (const entry of discovered) {
const existing = bySchema.get(entry.schema) ?? [];
existing.push(entry);
bySchema.set(entry.schema, existing);
}
const schemaList = [...bySchema.keys()].sort();
const schemaSummary = schemaList.map((s) => `${s} (${bySchema.get(s)!.length})`).join(', ');
let selected: string[] | null = null;
while (selected === null) {
const action = await input.prompts.select({
message: `Tables found in selected schemas\n` +
`${discovered.length} tables across ${schemaList.length} ${schemaList.length === 1 ? 'schema' : 'schemas'}: ${schemaSummary}`,
options: [
{ value: 'all', label: 'Enable all tables' },
{ value: 'customize', label: 'Customize which tables to enable' },
{ value: 'back', label: 'Back' },
],
});
if (action === 'back') {
enabledTables = allQualified;
activeSchemas = spec ? schemasInDiscovery : [];
} else {
const pickResult = await (input.deps.pickDatabaseScope ?? defaultPickDatabaseScope)(
{
connectionId: input.connectionId,
schemaNoun: spec?.noun ?? 'schema',
schemaNounPlural: spec?.nounPlural ?? 'schemas',
discovered,
existing: { enabledTables: existingEnabled },
defaultSchemas,
supportsSchemaScope: spec !== undefined,
},
input.io,
);
if (pickResult.kind === 'back') {
return 'back';
}
if (action === 'all') {
selected = allQualified;
} else {
const choices = await input.prompts.multiselect({
message: withMultiselectNavigation(
`Tables to enable for ${input.connectionId}\n` +
`Deselect any tables agents should not use.`,
),
options: discovered.map((t) => {
const qualified = `${t.schema}.${t.name}`;
const suffix = t.kind === 'view' ? ' (view)' : '';
return { value: qualified, label: `${qualified}${suffix}` };
}),
initialValues:
Array.isArray(existingTables) && input.forcePrompt === true
? existingTables.filter((table): table is string => typeof table === 'string' && allQualified.includes(table))
: allQualified,
required: true,
});
if (choices.includes('back')) {
continue;
}
if (choices.length === 0) {
input.io.stdout.write('│ KTX needs at least one table enabled. Select a table or press Escape to go back.\n');
continue;
}
selected = choices;
}
enabledTables = pickResult.enabledTables;
activeSchemas = pickResult.activeSchemas;
}
if (spec) {
await writeScopeConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
values: activeSchemas,
spec,
});
}
const refreshedProject = await loadKtxProject({ projectDir: input.projectDir });
const currentConnection = refreshedProject.config.connections[input.connectionId];
if (!currentConnection) return 'ready';
await writeConnectionConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
connection: { ...connection!, enabled_tables: selected },
connection: { ...currentConnection, enabled_tables: enabledTables },
});
if (spec && activeSchemas.length > 0) {
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
`${activeSchemas.join(', ')}`,
]);
}
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
`${selected.length}/${discovered.length} tables enabled`,
`${enabledTables.length}/${discovered.length} tables enabled`,
]);
return 'ready';
}
@ -1638,26 +1589,9 @@ async function validateAndScanConnection(input: {
const testLines = ['✓ Connection test passed', `Driver: ${driverDisplay}`];
writeSetupSection(input.io, `Testing ${input.connectionId}`, testLines);
while (true) {
const schemaStatus = await maybeConfigureSchemaScope({ ...input, forcePrompt: input.forceScopeAndTables });
if (schemaStatus !== 'ready') {
return schemaStatus;
}
const tableStatus = await maybeConfigureTableScope({ ...input, forcePrompt: input.forceScopeAndTables });
if (tableStatus === 'ready') {
break;
}
if (input.forceScopeAndTables) {
return tableStatus;
}
if (tableStatus === 'failed') {
return 'failed';
}
await clearScopeConfig(input.projectDir, input.connectionId);
const scopeStatus = await maybeConfigureDatabaseScope({ ...input, forcePrompt: input.forceScopeAndTables });
if (scopeStatus !== 'ready') {
return scopeStatus;
}
await maybeRunHistoricSqlSetupProbe({

View file

@ -0,0 +1,614 @@
import type {
KtxLocalProject,
KtxProjectConfig,
KtxProjectConnectionConfig,
KtxProjectEmbeddingConfig,
KtxProjectLlmConfig,
} from '@ktx/context/project';
import type { DoctorCheck } from './doctor.js';
type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
type ProjectVerdict = 'ready' | 'partial' | 'blocked';
interface ProjectStatusLine {
status: ProjectStatusLevel;
detail: string;
fix?: string;
}
interface LlmStatus extends ProjectStatusLine {
backend: string;
model?: string;
}
interface EmbeddingsStatus extends ProjectStatusLine {
backend: string;
model?: string;
dimensions?: number;
}
interface ConnectionStatus extends ProjectStatusLine {
name: string;
driver: string;
}
interface PipelineStatus {
adapters: string[];
enrichmentMode: string;
relationshipsEnabled: boolean;
relationshipsLlmProposals: boolean;
relationshipsValidationRequired: boolean;
agentEnabled: boolean;
agentTools: string[];
agentMaxIterations: number;
}
interface StorageStatus {
state: string;
search: string;
gitAutoCommit: boolean;
gitAuthor: string;
}
interface WarningItem {
message: string;
fix?: string;
}
export interface ProjectStatus {
projectName: string;
projectDir: string;
llm: LlmStatus;
embeddings: EmbeddingsStatus;
storage: StorageStatus;
connections: ConnectionStatus[];
pipeline: PipelineStatus;
warnings: WarningItem[];
verdict: ProjectVerdict;
verdictReason: string;
nextActions: string[];
promptCaching?: { enabled: boolean; systemTtl?: string; toolsTtl?: string; historyTtl?: string };
workUnits?: { stepBudget: number; maxConcurrency: number; failureMode: string };
memoryAutoCommit: boolean;
relationshipsDetail?: {
acceptThreshold: number;
reviewThreshold: number;
maxLlmTablesPerBatch: number;
validationConcurrency: number;
};
}
function resolveRef(value: unknown, env: NodeJS.ProcessEnv): { resolved: string; via: 'literal' | 'env' | 'file' | 'missing' } {
if (typeof value !== 'string') return { resolved: '', via: 'missing' };
const trimmed = value.trim();
if (trimmed.length === 0) return { resolved: '', via: 'missing' };
if (trimmed.startsWith('env:')) {
const name = trimmed.slice(4).trim();
const v = env[name];
return v && v.trim().length > 0 ? { resolved: v, via: 'env' } : { resolved: '', via: 'missing' };
}
if (trimmed.startsWith('file:')) {
return { resolved: trimmed.slice(5), via: 'file' };
}
return { resolved: trimmed, via: 'literal' };
}
function envHint(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim().startsWith('env:')) {
return value.trim().slice(4).trim();
}
return undefined;
}
function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): LlmStatus {
const backend = config.provider.backend;
const model = config.models?.default;
if (backend === 'none') {
return {
backend,
model,
status: 'fail',
detail: 'no LLM configured — ktx ask will not work',
fix: 'Run: ktx setup (choose an LLM provider)',
};
}
if (backend === 'anthropic') {
const ref = config.provider.anthropic?.api_key;
const resolved = resolveRef(ref, env);
if (resolved.resolved.length > 0) {
return { backend, model, status: 'ok', detail: `key set${resolved.via === 'env' ? ` (env)` : ''}` };
}
if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_API_KEY.trim().length > 0) {
return { backend, model, status: 'ok', detail: 'key set (env: ANTHROPIC_API_KEY)' };
}
const hint = envHint(ref);
return {
backend,
model,
status: 'warn',
detail: hint ? `key missing (env: ${hint})` : 'key missing',
fix: hint ? `Set ${hint}` : 'Set ANTHROPIC_API_KEY or rerun `ktx setup`',
};
}
if (backend === 'vertex') {
const project = config.provider.vertex?.project;
if (project && project.length > 0) {
return { backend, model, status: 'ok', detail: `project=${project}` };
}
return { backend, model, status: 'warn', detail: 'vertex project not configured', fix: 'Rerun `ktx setup`' };
}
if (backend === 'gateway') {
const ref = config.provider.gateway?.api_key;
const resolved = resolveRef(ref, env);
if (resolved.resolved.length > 0) {
return { backend, model, status: 'ok', detail: 'key set' };
}
const hint = envHint(ref);
return {
backend,
model,
status: 'warn',
detail: hint ? `key missing (env: ${hint})` : 'key missing',
fix: hint ? `Set ${hint}` : 'Set the gateway api_key or rerun `ktx setup`',
};
}
return { backend, model, status: 'warn', detail: 'unknown LLM backend' };
}
function buildEmbeddingsStatus(config: KtxProjectEmbeddingConfig, env: NodeJS.ProcessEnv): EmbeddingsStatus {
const backend = config.backend;
const model = config.model;
const dimensions = config.dimensions;
if (backend === 'none') {
return {
backend,
model,
dimensions,
status: 'warn',
detail: 'disabled — semantic search will be skipped',
};
}
if (backend === 'deterministic') {
return {
backend,
model,
dimensions,
status: 'warn',
detail: 'deterministic — semantic search degraded (lexical/dictionary lanes still work)',
};
}
if (backend === 'openai') {
const ref = config.openai?.api_key;
const resolved = resolveRef(ref, env);
if (resolved.resolved.length > 0 || (env.OPENAI_API_KEY && env.OPENAI_API_KEY.trim().length > 0)) {
return { backend, model, dimensions, status: 'ok', detail: 'key set' };
}
const hint = envHint(ref);
return {
backend,
model,
dimensions,
status: 'warn',
detail: hint ? `key missing (env: ${hint})` : 'key missing',
fix: hint ? `Set ${hint}` : 'Set OPENAI_API_KEY or rerun `ktx setup`',
};
}
if (backend === 'sentence-transformers') {
const url = config.sentenceTransformers?.base_url;
if (typeof url === 'string' && url.length > 0) {
return { backend, model, dimensions, status: 'ok', detail: `service: ${url}` };
}
return {
backend,
model,
dimensions,
status: 'warn',
detail: 'no base_url configured',
fix: 'Rerun `ktx setup`',
};
}
return { backend, model, dimensions, status: 'warn', detail: 'unknown embedding backend' };
}
function buildConnectionStatus(
name: string,
conn: KtxProjectConnectionConfig,
env: NodeJS.ProcessEnv,
): ConnectionStatus {
const driver = (conn.driver ?? 'unknown').toLowerCase();
const ok = (detail: string): ConnectionStatus => ({ name, driver, status: 'ok', detail });
const warn = (detail: string, fix?: string): ConnectionStatus => ({ name, driver, status: 'warn', detail, fix });
switch (driver) {
case 'postgres':
case 'postgresql':
case 'mysql':
case 'clickhouse':
case 'sqlserver': {
const urlRef = resolveRef(conn.url, env);
if (urlRef.resolved.length > 0) return ok(`url configured`);
if (typeof (conn as Record<string, unknown>).host === 'string') return ok('host configured');
const hint = envHint(conn.url);
return warn(hint ? `url missing (env: ${hint})` : 'url not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
}
case 'snowflake': {
const account = (conn as Record<string, unknown>).account;
if (typeof account === 'string' && account.length > 0) return ok(`account: ${account}`);
return warn('account not set', 'Rerun `ktx setup`');
}
case 'bigquery': {
const cred = resolveRef((conn as Record<string, unknown>).credentials_json, env);
if (cred.resolved.length > 0) return ok('credentials configured');
const hint = envHint((conn as Record<string, unknown>).credentials_json);
return warn(hint ? `credentials missing (env: ${hint})` : 'credentials not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
}
case 'sqlite': {
const path = (conn as Record<string, unknown>).path;
if (typeof path === 'string' && path.length > 0) return ok(`path: ${path}`);
return warn('path not set', 'Rerun `ktx setup`');
}
case 'notion': {
const tokenRef =
(conn as Record<string, unknown>).auth_token_ref ??
(conn as Record<string, unknown>).auth_token;
const resolved = resolveRef(tokenRef, env);
if (resolved.resolved.length > 0) return ok('auth token configured');
const hint = envHint(tokenRef);
return warn(hint ? `auth token missing (env: ${hint})` : 'auth token not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
}
case 'dbt':
case 'dbt-core':
case 'dbt-cloud': {
const repoUrl =
(conn as Record<string, unknown>).repoUrl ??
(conn as Record<string, unknown>).repo_url;
if (typeof repoUrl === 'string' && repoUrl.length > 0) return ok(`repo: ${repoUrl}`);
return warn('repoUrl not set', 'Rerun `ktx setup`');
}
case 'metabase': {
const url = (conn as Record<string, unknown>).url ?? (conn as Record<string, unknown>).base_url;
if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`);
return warn('url not set', 'Rerun `ktx setup`');
}
case 'looker':
case 'lookml': {
const url = (conn as Record<string, unknown>).base_url ?? (conn as Record<string, unknown>).url;
if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`);
return warn('base_url not set', 'Rerun `ktx setup`');
}
case 'metricflow': {
const repoUrl = (conn as Record<string, unknown>).repoUrl ?? (conn as Record<string, unknown>).repo_url;
if (typeof repoUrl === 'string' && repoUrl.length > 0) return ok(`repo: ${repoUrl}`);
return warn('repoUrl not set', 'Rerun `ktx setup`');
}
default:
return { name, driver, status: 'ok', detail: 'configured' };
}
}
const ADAPTER_DRIVER_REQUIREMENT: Record<string, string[]> = {
'live-database': ['postgres', 'postgresql', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'],
dbt: ['dbt', 'dbt-core', 'dbt-cloud'],
notion: ['notion'],
metabase: ['metabase'],
looker: ['looker', 'lookml'],
lookml: ['looker', 'lookml'],
metricflow: ['metricflow'],
};
function buildPipelineStatus(config: KtxProjectConfig): PipelineStatus {
return {
adapters: config.ingest.adapters,
enrichmentMode: config.scan.enrichment.mode,
relationshipsEnabled: config.scan.relationships.enabled,
relationshipsLlmProposals: config.scan.relationships.llmProposals,
relationshipsValidationRequired: config.scan.relationships.validationRequiredForManifest,
agentEnabled: config.agent.run_research.enabled,
agentTools: config.agent.run_research.default_toolset,
agentMaxIterations: config.agent.run_research.max_iterations,
};
}
function buildStorageStatus(config: KtxProjectConfig): StorageStatus {
return {
state: config.storage.state,
search: config.storage.search,
gitAutoCommit: config.storage.git.auto_commit,
gitAuthor: config.storage.git.author,
};
}
function buildWarnings(
config: KtxProjectConfig,
connections: ConnectionStatus[],
llm: LlmStatus,
embeddings: EmbeddingsStatus,
): WarningItem[] {
const warnings: WarningItem[] = [];
for (const adapter of config.ingest.adapters) {
const requiredDrivers = ADAPTER_DRIVER_REQUIREMENT[adapter];
if (!requiredDrivers) continue;
const hasMatching = connections.some((c) => requiredDrivers.includes(c.driver));
if (!hasMatching) {
warnings.push({
message: `Adapter "${adapter}" is enabled but no connection of type ${requiredDrivers.slice(0, 2).join('/')} is configured.`,
fix: 'Rerun `ktx setup` to add a connection, or remove the adapter from ingest.adapters.',
});
}
}
if (config.agent.run_research.enabled && llm.backend === 'none') {
warnings.push({
message: 'Research agent is enabled but LLM is not configured.',
fix: 'Set up an LLM provider via `ktx setup` or disable agent.run_research.enabled.',
});
}
if (embeddings.backend === 'none' && config.ingest.adapters.includes('live-database')) {
warnings.push({
message: 'Semantic search is off (embeddings backend = none). Lexical/dictionary lanes still work.',
});
}
return warnings;
}
function buildVerdict(
llm: LlmStatus,
embeddings: EmbeddingsStatus,
connections: ConnectionStatus[],
warnings: WarningItem[],
): { verdict: ProjectVerdict; reason: string; nextActions: string[] } {
if (llm.status === 'fail') {
return {
verdict: 'blocked',
reason: 'LLM not configured — `ktx ask` will not work.',
nextActions: ['ktx setup'],
};
}
const reasons: string[] = [];
if (llm.status === 'warn') reasons.push('LLM credentials missing');
if (embeddings.status === 'warn') {
if (embeddings.backend === 'deterministic' || embeddings.backend === 'none') {
reasons.push('semantic search disabled');
} else {
reasons.push('embedding credentials missing');
}
}
const missing = connections.filter((c) => c.status !== 'ok').length;
if (missing > 0) reasons.push(`${missing} connection${missing === 1 ? '' : 's'} need configuration`);
if (warnings.length > 0) reasons.push(`${warnings.length} config warning${warnings.length === 1 ? '' : 's'}`);
if (reasons.length === 0) {
return {
verdict: 'ready',
reason: 'Ready.',
nextActions: ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'],
};
}
return {
verdict: 'partial',
reason: `Partially ready — ${reasons.join('; ')}.`,
nextActions: ['ktx setup'],
};
}
export interface BuildProjectStatusOptions {
env?: NodeJS.ProcessEnv;
}
export function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): ProjectStatus {
const env = options.env ?? process.env;
const config = project.config;
const llm = buildLlmStatus(config.llm, env);
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
const storage = buildStorageStatus(config);
const connections = Object.entries(config.connections).map(([name, conn]) =>
buildConnectionStatus(name, conn, env),
);
const pipeline = buildPipelineStatus(config);
const warnings = buildWarnings(config, connections, llm, embeddings);
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, warnings);
return {
projectName: config.project,
projectDir: project.projectDir,
llm,
embeddings,
storage,
connections,
pipeline,
warnings,
verdict,
verdictReason: reason,
nextActions,
promptCaching: config.llm.promptCaching
? {
enabled: config.llm.promptCaching.enabled ?? false,
systemTtl: config.llm.promptCaching.systemTtl,
toolsTtl: config.llm.promptCaching.toolsTtl,
historyTtl: config.llm.promptCaching.historyTtl,
}
: undefined,
workUnits: {
stepBudget: config.ingest.workUnits.stepBudget,
maxConcurrency: config.ingest.workUnits.maxConcurrency,
failureMode: config.ingest.workUnits.failureMode,
},
memoryAutoCommit: config.memory.auto_commit,
relationshipsDetail: {
acceptThreshold: config.scan.relationships.acceptThreshold,
reviewThreshold: config.scan.relationships.reviewThreshold,
maxLlmTablesPerBatch: config.scan.relationships.maxLlmTablesPerBatch,
validationConcurrency: config.scan.relationships.validationConcurrency,
},
};
}
// ─── Rendering ──────────────────────────────────────────────────────────────
const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗' };
function ansi(useColor: boolean, code: string, text: string, closer = '39'): string {
return useColor ? `\u001b[${code}m${text}\u001b[${closer}m` : text;
}
function colorFor(level: ProjectStatusLevel): string {
return level === 'ok' ? '32' : level === 'warn' ? '33' : '31';
}
function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string {
const home = env.HOME;
if (home && (filePath === home || filePath.startsWith(`${home}/`))) {
return filePath === home ? '~' : `~${filePath.slice(home.length)}`;
}
return filePath;
}
export interface RenderProjectStatusOptions {
verbose?: boolean;
useColor?: boolean;
durationMs?: number;
toolchainChecks?: DoctorCheck[];
env?: NodeJS.ProcessEnv;
}
export function renderProjectStatus(status: ProjectStatus, options: RenderProjectStatusOptions = {}): string {
const verbose = options.verbose ?? false;
const useColor = options.useColor ?? false;
const env = options.env ?? process.env;
const dim = (s: string) => ansi(useColor, '2', s, '22');
const bold = (s: string) => ansi(useColor, '1', s, '22');
const color = (level: ProjectStatusLevel, s: string) => ansi(useColor, colorFor(level), s);
const sym = (level: ProjectStatusLevel) => color(level, SYMBOL[level]);
const lines: string[] = [];
const dirStr = abbreviateHome(status.projectDir, env);
lines.push(`${bold('KTX status')} ${dim('·')} ${status.projectName} ${dim(`(${dirStr})`)}`);
lines.push('');
const labelPad = 'Connections'.length;
const label = (text: string) => text.padEnd(labelPad);
// Core readiness rows
const llmDetail = [status.llm.backend, status.llm.model].filter(Boolean).join(` ${dim('·')} `);
lines.push(` ${label('LLM')} ${llmDetail} ${sym(status.llm.status)} ${dim(status.llm.detail)}`);
const embedParts = [status.embeddings.backend];
if (status.embeddings.model) embedParts.push(status.embeddings.model);
const embedDim = status.embeddings.dimensions ? `(${status.embeddings.dimensions}d)` : '';
const embedDetail = `${embedParts.join(` ${dim('·')} `)}${embedDim ? ` ${embedDim}` : ''}`;
lines.push(` ${label('Embeddings')} ${embedDetail} ${sym(status.embeddings.status)} ${dim(status.embeddings.detail)}`);
lines.push(` ${label('Storage')} ${dim(`${status.storage.state} (state) · ${status.storage.search} (search)`)}`);
lines.push('');
// Connections
if (status.connections.length === 0) {
lines.push(` ${bold('Connections')} ${dim('(none)')}`);
lines.push(` ${dim('No connections configured. Run `ktx setup` to add one.')}`);
} else {
lines.push(` ${bold('Connections')} ${dim(`(${status.connections.length})`)}`);
const nameWidth = Math.max(...status.connections.map((c) => c.name.length));
const driverWidth = Math.max(...status.connections.map((c) => c.driver.length));
for (const conn of status.connections) {
lines.push(
` ${sym(conn.status)} ${conn.name.padEnd(nameWidth)} ${dim(conn.driver.padEnd(driverWidth))} ${conn.detail}`,
);
if (conn.fix && conn.status !== 'ok') {
const indent = 6 + nameWidth + 3 + driverWidth + 3;
lines.push(`${' '.repeat(indent)}${dim(`${conn.fix}`)}`);
}
}
}
lines.push('');
// Pipeline
lines.push(` ${bold('Pipeline')}`);
const pipelineLabelWidth = Math.max('Adapters'.length, 'Enrichment'.length, 'Research agent'.length);
const pLabel = (text: string) => text.padEnd(pipelineLabelWidth);
lines.push(` ${pLabel('Adapters')} ${status.pipeline.adapters.length > 0 ? status.pipeline.adapters.join(', ') : dim('(none)')}`);
const enrichmentDetail = [`${status.pipeline.enrichmentMode} mode`];
if (status.pipeline.relationshipsEnabled) {
const bits = ['relationships on'];
if (status.pipeline.relationshipsLlmProposals) bits.push('LLM proposals');
if (status.pipeline.relationshipsValidationRequired) bits.push('validation required');
enrichmentDetail.push(bits.join(', '));
} else {
enrichmentDetail.push('relationships off');
}
lines.push(` ${pLabel('Enrichment')} ${enrichmentDetail.join(` ${dim('·')} `)}`);
const agentDetail = status.pipeline.agentEnabled
? `enabled ${dim(`(${status.pipeline.agentTools.length} tool${status.pipeline.agentTools.length === 1 ? '' : 's'})`)}`
: dim('disabled');
lines.push(` ${pLabel('Research agent')} ${agentDetail}`);
lines.push('');
// Warnings
if (status.warnings.length > 0) {
lines.push(` ${bold('Warnings')}`);
for (const w of status.warnings) {
lines.push(` ${color('warn', SYMBOL.warn)} ${w.message}`);
if (w.fix) lines.push(` ${dim(`${w.fix}`)}`);
}
lines.push('');
}
// Verbose extras
if (verbose) {
if (options.toolchainChecks && options.toolchainChecks.length > 0) {
lines.push(` ${bold('Toolchain')}`);
for (const check of options.toolchainChecks) {
const lv: ProjectStatusLevel = check.status === 'pass' ? 'ok' : check.status === 'warn' ? 'warn' : 'fail';
lines.push(` ${sym(lv)} ${check.label}: ${check.detail}`);
if (check.fix && lv !== 'ok') lines.push(` ${dim(`${check.fix}`)}`);
}
lines.push('');
}
if (status.promptCaching) {
const pc = status.promptCaching;
const bits = [`enabled=${pc.enabled}`];
if (pc.systemTtl) bits.push(`system=${pc.systemTtl}`);
if (pc.toolsTtl) bits.push(`tools=${pc.toolsTtl}`);
if (pc.historyTtl) bits.push(`history=${pc.historyTtl}`);
lines.push(` ${bold('Prompt caching')} ${dim(bits.join(', '))}`);
}
if (status.workUnits) {
const wu = status.workUnits;
lines.push(` ${bold('Work units')} ${dim(`stepBudget=${wu.stepBudget}, maxConcurrency=${wu.maxConcurrency}, failureMode=${wu.failureMode}`)}`);
}
if (status.relationshipsDetail) {
const r = status.relationshipsDetail;
lines.push(
` ${bold('Relationships')} ${dim(`accept=${r.acceptThreshold}, review=${r.reviewThreshold}, maxLlmTables=${r.maxLlmTablesPerBatch}, concurrency=${r.validationConcurrency}`)}`,
);
}
lines.push(
` ${bold('Agent')} ${dim(`max_iterations=${status.pipeline.agentMaxIterations}, tools=${status.pipeline.agentTools.join(', ') || '(none)'}`)}`,
);
lines.push(` ${bold('Memory')} ${dim(`auto_commit=${status.memoryAutoCommit}`)}`);
lines.push(
` ${bold('Git')} ${dim(`auto_commit=${status.storage.gitAutoCommit}, author=${status.storage.gitAuthor}`)}`,
);
lines.push('');
}
// Verdict + next steps
const verdictLevel: ProjectStatusLevel =
status.verdict === 'ready' ? 'ok' : status.verdict === 'partial' ? 'warn' : 'fail';
const duration = options.durationMs !== undefined ? ` ${dim(`(${(options.durationMs / 1000).toFixed(2)}s)`)}` : '';
if (status.verdict === 'ready') {
const hint = ` ${dim('Try:')} ${status.nextActions.join(dim(' · '))}`;
lines.push(`${color(verdictLevel, status.verdictReason)}${hint}${duration}`);
} else {
const hint = status.nextActions.length > 0 ? ` ${dim('Next:')} ${status.nextActions.join(dim(' · '))}` : '';
lines.push(`${color(verdictLevel, status.verdictReason)}${hint}${duration}`);
}
lines.push('');
return lines.join('\n');
}

View file

@ -11,10 +11,9 @@ import {
selectAllVisible,
selectNone,
toggleChecked,
TRANSIENT_HINT_DURATION_MS,
visibleNodeIds,
type NotionPickerPageInput,
} from './notion-page-picker-tree.js';
type TreePickerNodeInput,
} from './tree-picker-state.js';
const IDS = {
engineering: '11111111-1111-1111-1111-111111111111',
@ -28,7 +27,7 @@ const IDS = {
cycleB: '99999999-9999-9999-9999-999999999999',
};
function pages(): NotionPickerPageInput[] {
function pages(): TreePickerNodeInput[] {
return [
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
{ id: IDS.onboarding, title: 'Onboarding', archived: false, parentId: IDS.engineering },
@ -44,7 +43,7 @@ function pages(): NotionPickerPageInput[] {
}
describe('buildPickerTree', () => {
it('deduplicates pages, sorts siblings, preserves archived flags, roots orphans, and breaks cycles', () => {
it('deduplicates nodes, sorts siblings, preserves archived flags, roots orphans, and breaks cycles', () => {
const tree = buildPickerTree(pages());
const byId = new Map(tree.map((node) => [node.id, node]));
@ -90,8 +89,7 @@ describe('selection invariants', () => {
it('checking a parent locks descendants and keeps checked ids minimal', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [],
});
const checkedParent = toggleChecked(state, IDS.engineering, 1000);
@ -113,15 +111,11 @@ describe('selection invariants', () => {
expect(canToggle(IDS.architecture, uncheckedParent)).toEqual({ ok: true });
});
it('normalizes stored roots, reports stale roots, expands checked ancestors, and flattens descendants', () => {
it('reports stale stored ids via the caller-supplied warning, expands checked ancestors, and flattens descendants', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [
IDS.engineering.replaceAll('-', ''),
IDS.architecture,
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [IDS.engineering, IDS.architecture, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'],
staleWarning: (staleCount) => `${staleCount} stored root_page_ids no longer visible`,
});
expect([...state.checked]).toEqual([IDS.engineering]);
@ -130,14 +124,21 @@ describe('selection invariants', () => {
expect(state.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
expect(flattenSelection(new Set([IDS.engineering, IDS.architecture]), state.byId)).toEqual([IDS.engineering]);
});
it('falls back to a generic stale warning when no warning factory is supplied', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingSelectedIds: ['aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'],
});
expect(state.preLoadWarnings).toEqual(['1 stored selections no longer visible']);
});
});
describe('search and cursor movement', () => {
it('filters by title and path while deriving auto-expanded ancestors', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [],
});
const searching = {
...state,
@ -154,8 +155,7 @@ describe('search and cursor movement', () => {
it('moves the cursor through visible nodes and implements left/right tree semantics', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [],
});
const atEngineering = {
@ -176,8 +176,7 @@ describe('bulk actions and reducer effects', () => {
it('selects only matching visible roots under search and clears selection', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [IDS.marketing],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [IDS.marketing],
});
const searching = {
...state,
@ -189,56 +188,56 @@ describe('bulk actions and reducer effects', () => {
expect([...selectNone(selected).checked]).toEqual([]);
});
it('returns save immediately for selected_roots and requires confirmation for all_accessible', () => {
const selectedRoots = toggleChecked(
it('saves immediately when confirm is not required and prompts confirmation when requireConfirmOnSave is true', () => {
const noConfirm = toggleChecked(
buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [],
}),
IDS.marketing,
1000,
);
expect(reducer(selectedRoots, 'save-request')).toEqual({
next: selectedRoots,
expect(reducer(noConfirm, 'save-request')).toEqual({
next: noConfirm,
effect: 'save',
});
const allAccessible = {
...selectedRoots,
currentCrawlMode: 'all_accessible' as const,
const confirmRequired = {
...noConfirm,
requireConfirmOnSave: true,
};
const confirm = reducer(allAccessible, 'save-request');
const confirm = reducer(confirmRequired, 'save-request');
expect(confirm).toEqual({
next: { ...allAccessible, pendingConfirm: 'mode-switch' },
next: { ...confirmRequired, pendingConfirm: 'save-confirm' },
effect: null,
});
expect(reducer(confirm.next, 'save-cancel')).toEqual({
next: { ...allAccessible, pendingConfirm: null },
next: { ...confirmRequired, pendingConfirm: null },
effect: null,
});
expect(reducer(confirm.next, 'save-confirm')).toEqual({
next: { ...allAccessible, pendingConfirm: null },
next: { ...confirmRequired, pendingConfirm: null },
effect: 'save',
});
});
it('blocks empty saves, updates search state, and quits without saving', () => {
it('prompts skip-empty confirmation on empty save, updates search state, and quits without saving', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [],
});
const blockedSave = reducer(state, 'save-request', 9000);
expect(blockedSave).toEqual({
next: {
...state,
transientHint: {
text: 'Select at least one page or press q to quit',
expiresAt: 9000 + TRANSIENT_HINT_DURATION_MS,
},
},
const emptySave = reducer(state, 'save-request');
expect(emptySave).toEqual({
next: { ...state, pendingConfirm: 'skip-empty' },
effect: null,
});
expect(reducer(emptySave.next, 'save-confirm')).toEqual({
next: { ...state, pendingConfirm: null },
effect: 'quit-without-save',
});
expect(reducer(emptySave.next, 'save-cancel')).toEqual({
next: { ...state, pendingConfirm: null },
effect: null,
});
expect(
@ -253,16 +252,33 @@ describe('bulk actions and reducer effects', () => {
});
});
it('treats skip-empty confirmation as a save with empty selection when skipEmptyAction is save-empty', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingSelectedIds: [],
skipEmptyAction: 'save-empty',
});
const emptySave = reducer(state, 'save-request');
expect(emptySave).toEqual({
next: { ...state, pendingConfirm: 'skip-empty' },
effect: null,
});
expect(reducer(emptySave.next, 'save-confirm')).toEqual({
next: { ...state, pendingConfirm: null },
effect: 'save',
});
});
it('clears transient hints only when their expiry time has passed', () => {
const state = buildInitialState({
tree: buildPickerTree(pages()),
existingRootPageIds: [],
currentCrawlMode: 'selected_roots',
existingSelectedIds: [],
});
const withHint = {
...state,
transientHint: {
text: 'Select at least one page or press q to quit',
text: 'Select at least one item or press esc to cancel',
expiresAt: 11500,
},
};

View file

@ -1,11 +1,11 @@
export interface NotionPickerPageInput {
export interface TreePickerNodeInput {
id: string;
title?: string | null;
archived?: boolean;
parentId?: string | null;
}
interface NotionPickerNode {
export interface TreePickerNode {
id: string;
title: string;
archived: boolean;
@ -15,17 +15,22 @@ interface NotionPickerNode {
path: string;
}
type PendingConfirmKind = 'save-confirm' | 'skip-empty';
export type SkipEmptyAction = 'quit' | 'save-empty';
export interface PickerState {
tree: NotionPickerNode[];
byId: Map<string, NotionPickerNode>;
tree: TreePickerNode[];
byId: Map<string, TreePickerNode>;
expanded: Set<string>;
checked: Set<string>;
cursorId: string;
search: { editing: boolean; query: string };
pendingConfirm: 'mode-switch' | null;
pendingConfirm: PendingConfirmKind | null;
preLoadWarnings: string[];
transientHint: { text: string; expiresAt: number } | null;
currentCrawlMode: 'all_accessible' | 'selected_roots';
requireConfirmOnSave: boolean;
skipEmptyAction: SkipEmptyAction;
}
export type PickerCommand =
@ -61,29 +66,16 @@ interface MutableNode {
childIds: string[];
}
export const TRANSIENT_HINT_DURATION_MS = 2500;
const TRANSIENT_HINT_DURATION_MS = 2500;
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
function normalizePageId(value: string): string {
const trimmed = value.trim();
const compact = trimmed.replace(/-/g, '');
if (/^[0-9a-fA-F]{32}$/.test(compact)) {
const lower = compact.toLowerCase();
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(
16,
20,
)}-${lower.slice(20)}`;
}
return trimmed;
}
function titleValue(value: string | null | undefined): string {
const trimmed = value?.trim() ?? '';
return trimmed.length > 0 ? trimmed : 'Untitled';
}
function sortedNodeIds(ids: string[], nodes: Map<string, MutableNode | NotionPickerNode>): string[] {
function sortedNodeIds(ids: string[], nodes: Map<string, MutableNode | TreePickerNode>): string[] {
return [...ids].sort((leftId, rightId) => {
const left = nodes.get(leftId);
const right = nodes.get(rightId);
@ -107,7 +99,7 @@ export function clearExpiredTransientHint(state: PickerState, now = Date.now()):
return cloneState(state, { transientHint: null });
}
function ancestorsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
function ancestorsOf(nodeId: string, byId: Map<string, TreePickerNode>): string[] {
const ancestors: string[] = [];
let parentId = byId.get(nodeId)?.parentId ?? null;
const seen = new Set<string>();
@ -119,7 +111,7 @@ function ancestorsOf(nodeId: string, byId: Map<string, NotionPickerNode>): strin
return ancestors;
}
function descendantsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
function descendantsOf(nodeId: string, byId: Map<string, TreePickerNode>): string[] {
const result: string[] = [];
const stack = [...(byId.get(nodeId)?.childIds ?? [])].reverse();
while (stack.length > 0) {
@ -152,18 +144,18 @@ function matchingIds(state: PickerState): Set<string> {
);
}
export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionPickerNode[] {
export function buildPickerTree(inputs: TreePickerNodeInput[]): TreePickerNode[] {
const nodes = new Map<string, MutableNode>();
for (const result of searchResults) {
const id = normalizePageId(result.id);
if (nodes.has(id)) {
for (const result of inputs) {
const id = result.id.trim();
if (id.length === 0 || nodes.has(id)) {
continue;
}
nodes.set(id, {
id,
title: titleValue(result.title),
archived: result.archived === true,
parentId: result.parentId ? normalizePageId(result.parentId) : null,
parentId: result.parentId ? result.parentId.trim() : null,
childIds: [],
});
}
@ -202,7 +194,7 @@ export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionP
[...nodes.values()].filter((node) => node.parentId === null).map((node) => node.id),
nodes,
);
const tree: NotionPickerNode[] = [];
const tree: TreePickerNode[] = [];
function visit(nodeId: string, depth: number, pathPrefix: string[]): void {
const raw = nodes.get(nodeId);
@ -210,7 +202,7 @@ export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionP
return;
}
const path = [...pathPrefix, raw.title].join(' / ');
const node: NotionPickerNode = {
const node: TreePickerNode = {
id: raw.id,
title: raw.title,
archived: raw.archived,
@ -232,11 +224,11 @@ export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionP
return tree;
}
export function isAncestorChecked(nodeId: string, checked: Set<string>, byId: Map<string, NotionPickerNode>): boolean {
export function isAncestorChecked(nodeId: string, checked: Set<string>, byId: Map<string, TreePickerNode>): boolean {
return ancestorsOf(nodeId, byId).some((ancestorId) => checked.has(ancestorId));
}
function checkedAncestor(nodeId: string, state: PickerState): NotionPickerNode | null {
function checkedAncestor(nodeId: string, state: PickerState): TreePickerNode | null {
for (const ancestorId of ancestorsOf(nodeId, state.byId)) {
if (state.checked.has(ancestorId)) {
return state.byId.get(ancestorId) ?? null;
@ -247,7 +239,7 @@ function checkedAncestor(nodeId: string, state: PickerState): NotionPickerNode |
export function canToggle(nodeId: string, state: PickerState): { ok: true } | { ok: false; reason: string } {
if (!state.byId.has(nodeId)) {
return { ok: false, reason: 'Page not found' };
return { ok: false, reason: 'Node not found' };
}
const ancestor = checkedAncestor(nodeId, state);
if (ancestor) {
@ -276,7 +268,7 @@ export function toggleChecked(state: PickerState, nodeId: string, now = Date.now
return cloneState(state, { checked, transientHint: null });
}
export function flattenSelection(checked: Set<string>, byId: Map<string, NotionPickerNode>): string[] {
export function flattenSelection(checked: Set<string>, byId: Map<string, TreePickerNode>): string[] {
const result: string[] = [];
for (const node of byId.values()) {
if (checked.has(node.id) && !isAncestorChecked(node.id, checked, byId)) {
@ -402,16 +394,21 @@ export function moveCursor(state: PickerState, dir: 'up' | 'down' | 'left' | 'ri
}
export function buildInitialState(args: {
tree: NotionPickerNode[];
existingRootPageIds: string[];
currentCrawlMode?: 'all_accessible' | 'selected_roots';
tree: TreePickerNode[];
existingSelectedIds: string[];
requireConfirmOnSave?: boolean;
skipEmptyAction?: SkipEmptyAction;
staleWarning?: (staleCount: number) => string;
}): PickerState {
const byId = new Map(args.tree.map((node) => [node.id, node]));
const checked = new Set<string>();
let staleCount = 0;
for (const rawId of args.existingRootPageIds) {
const id = normalizePageId(rawId);
for (const rawId of args.existingSelectedIds) {
const id = rawId.trim();
if (id.length === 0) {
continue;
}
if (byId.has(id)) {
checked.add(id);
} else {
@ -427,6 +424,12 @@ export function buildInitialState(args: {
}
}
const preLoadWarnings: string[] = [];
if (staleCount > 0) {
const warning = args.staleWarning ? args.staleWarning(staleCount) : `${staleCount} stored selections no longer visible`;
preLoadWarnings.push(warning);
}
return {
tree: args.tree,
byId,
@ -435,16 +438,19 @@ export function buildInitialState(args: {
cursorId: args.tree[0]?.id ?? '',
search: { editing: false, query: '' },
pendingConfirm: null,
preLoadWarnings: staleCount > 0 ? [`${staleCount} stored root_page_ids no longer visible`] : [],
preLoadWarnings,
transientHint: null,
currentCrawlMode: args.currentCrawlMode ?? 'selected_roots',
requireConfirmOnSave: args.requireConfirmOnSave ?? false,
skipEmptyAction: args.skipEmptyAction ?? 'quit',
};
}
export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } {
if (state.pendingConfirm) {
if (cmd === 'save-confirm') {
return { next: cloneState(state, { pendingConfirm: null }), effect: 'save' };
const effect: PickerEffect =
state.pendingConfirm === 'skip-empty' ? (state.skipEmptyAction === 'save-empty' ? 'save' : 'quit-without-save') : 'save';
return { next: cloneState(state, { pendingConfirm: null }), effect };
}
if (cmd === 'save-cancel') {
return { next: cloneState(state, { pendingConfirm: null }), effect: null };
@ -498,19 +504,13 @@ export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()
};
case 'save-request':
if (state.checked.size === 0) {
return {
next: cloneState(state, {
transientHint: transientHint('Select at least one page or press q to quit', now),
}),
effect: null,
};
return { next: cloneState(state, { pendingConfirm: 'skip-empty' }), effect: null };
}
if (state.currentCrawlMode === 'all_accessible') {
return { next: cloneState(state, { pendingConfirm: 'mode-switch' }), effect: null };
if (state.requireConfirmOnSave) {
return { next: cloneState(state, { pendingConfirm: 'save-confirm' }), effect: null };
}
return { next: state, effect: 'save' };
case 'save-confirm':
return { next: state, effect: 'save' };
case 'save-cancel':
return { next: state, effect: null };
case 'quit':

View file

@ -0,0 +1,361 @@
/* @jsxImportSource react */
import { render as renderInkTest } from 'ink-testing-library';
import { type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { buildInitialState, buildPickerTree, type TreePickerNodeInput } from './tree-picker-state.js';
import {
TreePickerApp,
renderTreePickerTui,
resolveTreePickerWidth,
sanitizeTreePickerTuiError,
treePickerCommandForInkInput,
windowItems,
windowOffset,
type TreePickerChrome,
type TreePickerInkInstance,
type TreePickerInkRenderOptions,
} from './tree-picker-tui.js';
const IDS = {
engineering: '11111111-1111-1111-1111-111111111111',
architecture: '22222222-2222-2222-2222-222222222222',
marketing: '33333333-3333-3333-3333-333333333333',
finance: '44444444-4444-4444-4444-444444444444',
ops: '55555555-5555-5555-5555-555555555555',
sales: '66666666-6666-6666-6666-666666666666',
support: '77777777-7777-7777-7777-777777777777',
product: '88888888-8888-8888-8888-888888888888',
design: '99999999-9999-9999-9999-999999999999',
};
function pages(): TreePickerNodeInput[] {
return [
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
];
}
function manyPages(): TreePickerNodeInput[] {
return [
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
{ id: IDS.finance, title: 'Finance', archived: false, parentId: null },
{ id: IDS.ops, title: 'Operations', archived: false, parentId: null },
{ id: IDS.sales, title: 'Sales', archived: false, parentId: null },
{ id: IDS.support, title: 'Support', archived: false, parentId: null },
{ id: IDS.product, title: 'Product', archived: false, parentId: null },
{ id: IDS.design, title: 'Design', archived: false, parentId: null },
];
}
function state(options: { requireConfirmOnSave?: boolean } = {}) {
return buildInitialState({
tree: buildPickerTree(pages()),
existingSelectedIds: [],
requireConfirmOnSave: options.requireConfirmOnSave ?? false,
});
}
function chrome(overrides: Partial<TreePickerChrome> = {}): TreePickerChrome {
return {
title: 'Select items',
subtitleLines: ['Source: Test'],
...overrides,
};
}
async function waitForInkInput(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
}
function fakeInkInstance(): TreePickerInkInstance {
return {
rerender: vi.fn(),
unmount: vi.fn(),
waitUntilExit: vi.fn(async () => undefined),
};
}
function normalizeFrameWrap(frame: string | undefined): string {
return frame?.replace(/\n/g, ' ').replace(/│ /g, '').replace(/ +/g, ' ') ?? '';
}
describe('treePickerCommandForInkInput', () => {
it('maps browse, search, and confirm input to reducer commands', () => {
expect(treePickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
expect(treePickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
expect(treePickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
expect(treePickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
expect(treePickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
expect(treePickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
expect(treePickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible');
expect(treePickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
expect(treePickerCommandForInkInput('', { return: true }, state().search, null)).toBe('save-request');
expect(treePickerCommandForInkInput('', { escape: true }, state().search, null)).toBe('quit');
expect(treePickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
expect(treePickerCommandForInkInput('s', {}, state().search, null)).toBeNull();
expect(treePickerCommandForInkInput('q', {}, state().search, null)).toBeNull();
expect(treePickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
type: 'search-input',
value: 'x',
});
expect(treePickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe(
'search-backspace',
);
expect(treePickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
'search-submit',
);
expect(treePickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
'search-cancel',
);
expect(treePickerCommandForInkInput('y', {}, state().search, 'save-confirm')).toBe('save-confirm');
expect(treePickerCommandForInkInput('', { return: true }, state().search, 'save-confirm')).toBe('save-confirm');
expect(treePickerCommandForInkInput('n', {}, state().search, 'save-confirm')).toBe('save-cancel');
});
});
describe('window helpers', () => {
it('centers the selected row and returns the visible slice', () => {
expect(windowOffset(20, 10, 5)).toBe(8);
expect(windowItems(['a', 'b', 'c', 'd', 'e'], 3, 3)).toEqual({ items: ['c', 'd', 'e'], offset: 2 });
});
it('clamps picker width to the design rule', () => {
expect(resolveTreePickerWidth(200)).toBe(120);
expect(resolveTreePickerWidth(100)).toBe(96);
expect(resolveTreePickerWidth(50)).toBe(60);
expect(resolveTreePickerWidth(undefined)).toBe(96);
});
});
describe('TreePickerApp', () => {
it('renders chrome title, subtitle, warnings, help, and row glyphs', () => {
const initialState = {
...state(),
preLoadWarnings: ['1 stale stored selections - they will be removed if you save'],
};
const { lastFrame } = renderInkTest(
<TreePickerApp
initialState={initialState}
chrome={chrome({
title: 'Select fancy widgets',
subtitleLines: ['Workspace: Design Workspace'],
warningLines: ['5000-page cap reached - some pages not shown'],
})}
terminalRows={24}
terminalWidth={100}
onExit={vi.fn()}
/>,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('Select fancy widgets');
expect(frame).toContain('Workspace: Design Workspace');
expect(frame).toContain('5000-page cap reached - some pages not shown');
expect(frame).toContain('1 stale stored selections - they will be removed if you save');
expect(frame).toContain('◻ Engineering Docs ▸ (1)');
expect(frame).toContain('◻ Marketing');
expect(normalizeFrameWrap(frame)).toContain(
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.',
);
});
it('renders custom help text when supplied', () => {
const { lastFrame } = renderInkTest(
<TreePickerApp
initialState={state()}
chrome={chrome({ helpText: 'Bespoke instructions here.' })}
terminalRows={24}
terminalWidth={100}
onExit={vi.fn()}
/>,
);
expect(lastFrame() ?? '').toContain('Bespoke instructions here.');
});
it('renders checked parents and locked descendants with locked glyphs', () => {
const initialState = {
...state(),
checked: new Set([IDS.engineering]),
expanded: new Set([IDS.engineering]),
};
const { lastFrame } = renderInkTest(
<TreePickerApp
initialState={initialState}
chrome={chrome()}
terminalRows={24}
terminalWidth={100}
onExit={vi.fn()}
/>,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('◼ Engineering Docs ▾');
expect(frame).toContain(' ◼ Architecture');
});
it('supports keyboard selection, confirm-on-save, and save callback', async () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderInkTest(
<TreePickerApp
initialState={state({ requireConfirmOnSave: true })}
chrome={chrome({
confirmSaveMessage: (current) =>
`Confirm: ${current.checked.size} item${current.checked.size === 1 ? '' : 's'}? Press Enter or Escape.`,
})}
terminalRows={24}
terminalWidth={100}
onExit={onExit}
/>,
);
stdin.write(' ');
await waitForInkInput();
expect(lastFrame()).toContain('◼ Engineering Docs');
stdin.write('\r');
await waitForInkInput();
expect(normalizeFrameWrap(lastFrame())).toContain('Confirm: 1 item? Press Enter or Escape.');
stdin.write('y');
await waitForInkInput();
expect(onExit).toHaveBeenCalledWith({ kind: 'save', selectedIds: [IDS.engineering] });
});
it('uses the chrome-supplied skip-empty message and quits on confirm', async () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderInkTest(
<TreePickerApp
initialState={state()}
chrome={chrome({ skipEmptyMessage: 'No selections. Skip or back?' })}
terminalRows={24}
terminalWidth={100}
onExit={onExit}
/>,
);
stdin.write('\r');
await waitForInkInput();
expect(normalizeFrameWrap(lastFrame())).toContain('No selections. Skip or back?');
expect(onExit).not.toHaveBeenCalled();
stdin.write('n');
await waitForInkInput();
expect(lastFrame()).not.toContain('No selections. Skip or back?');
expect(onExit).not.toHaveBeenCalled();
stdin.write('\r');
await waitForInkInput();
expect(lastFrame()).toContain('No selections. Skip or back?');
stdin.write('\r');
await waitForInkInput();
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
});
it('renders row-window overflow indicators when the visible list is clipped', async () => {
const onExit = vi.fn();
const initialState = buildInitialState({
tree: buildPickerTree(manyPages()),
existingSelectedIds: [],
});
initialState.expanded = new Set([IDS.engineering]);
const { stdin, lastFrame } = renderInkTest(
<TreePickerApp
initialState={initialState}
chrome={chrome()}
terminalRows={13}
terminalWidth={100}
onExit={onExit}
/>,
);
expect(lastFrame()).toContain('↓ 4 more');
stdin.write('');
stdin.write('');
stdin.write('');
stdin.write('');
await waitForInkInput();
const frame = lastFrame() ?? '';
expect(frame).toContain('↑ ');
expect(frame).toContain('↓ ');
expect(onExit).not.toHaveBeenCalled();
});
it('quits without saving on Ctrl+C', async () => {
const onExit = vi.fn();
const { stdin } = renderInkTest(
<TreePickerApp
initialState={state()}
chrome={chrome()}
terminalRows={24}
terminalWidth={100}
onExit={onExit}
/>,
);
stdin.write('');
await waitForInkInput();
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
});
});
describe('renderTreePickerTui', () => {
it('returns the app result from the Ink runtime', async () => {
const io = {
stdin: { isTTY: true, setRawMode: vi.fn() },
stdout: { isTTY: true, columns: 100, rows: 24, write: vi.fn() },
stderr: { write: vi.fn() },
};
const renderInk = vi.fn((_tree: ReactNode, _options: TreePickerInkRenderOptions) => fakeInkInstance());
await expect(
renderTreePickerTui(
{ initialState: state(), chrome: chrome() },
io,
{ renderInk },
),
).resolves.toEqual({ kind: 'quit' });
expect(renderInk).toHaveBeenCalledOnce();
});
it('sanitizes render errors and uses the supplied scripted-mode hint', async () => {
expect(sanitizeTreePickerTuiError(new Error('token=secret https://api.example.com/v1/search'))).toBe(
'[redacted] [redacted-url]',
);
});
it('falls back to quit with the scripted-mode hint when Ink cannot initialize', async () => {
let stderr = '';
const io = {
stdin: { isTTY: false, setRawMode: vi.fn() },
stdout: { isTTY: false, columns: 100, rows: 24, write: vi.fn() },
stderr: {
write(chunk: string) {
stderr += chunk;
},
},
};
await expect(
renderTreePickerTui(
{ initialState: state(), chrome: chrome() },
io,
{
renderInk: vi.fn(() => {
throw new Error('token=secret');
}),
scriptedModeHint: 'Use --no-input --foo bar for scripted mode.',
},
),
).resolves.toEqual({ kind: 'quit' });
expect(stderr).toContain('Use --no-input --foo bar for scripted mode.');
expect(stderr).not.toContain('secret');
});
});

View file

@ -9,13 +9,14 @@ import {
visibleNodeIds,
type PickerCommand,
type PickerState,
} from './notion-page-picker-tree.js';
} from './tree-picker-state.js';
import type { KtxCliIo } from './cli-runtime.js';
const COLOR_THEME = {
text: 'white',
muted: 'gray',
active: 'cyan',
selected: 'green',
warning: 'yellow',
} as const;
@ -23,12 +24,19 @@ const NO_COLOR_THEME = {
text: 'white',
muted: 'white',
active: 'white',
selected: 'white',
warning: 'white',
} as const;
type NotionPickerTheme = Record<keyof typeof COLOR_THEME, string>;
type TreePickerTheme = Record<keyof typeof COLOR_THEME, string>;
export interface NotionPickerTuiIo extends KtxCliIo {
const DEFAULT_TREE_PICKER_HELP_TEXT =
'Right Arrow to expand, Up/Down to move, Space to select or unselect, Slash to filter, Enter to confirm, Escape to go back, or Ctrl+C to exit.';
const DEFAULT_SKIP_EMPTY_MESSAGE =
'Nothing selected. Skip this step? Press Enter to skip or Escape to go back.';
export interface TreePickerTuiIo extends KtxCliIo {
stdin?: { isTTY?: boolean; setRawMode?(value: boolean): void };
stdout: KtxCliIo['stdout'] & { isTTY?: boolean; columns?: number; rows?: number };
}
@ -45,58 +53,54 @@ interface InkKey {
delete?: boolean;
}
export type PickerRenderResult = { kind: 'save'; rootPageIds: string[] } | { kind: 'quit' };
export type TreePickerResult = { kind: 'save'; selectedIds: string[] } | { kind: 'quit' };
export interface PickerRenderInput {
initialState: PickerState;
connectionId: string;
workspaceLabel: string;
cappedAtCount: number | null;
currentCrawlMode: 'all_accessible' | 'selected_roots';
export interface TreePickerChrome {
title: string;
helpText?: string;
subtitleLines?: readonly string[];
warningLines?: readonly string[];
confirmSaveMessage?: (state: PickerState) => string;
skipEmptyMessage?: string;
}
interface NotionPickerAppProps extends PickerRenderInput {
export interface TreePickerRenderInput {
initialState: PickerState;
chrome: TreePickerChrome;
}
interface TreePickerAppProps extends TreePickerRenderInput {
terminalRows?: number;
terminalWidth?: number;
env?: NodeJS.ProcessEnv;
onExit(result: PickerRenderResult): void;
onExit(result: TreePickerResult): void;
}
export interface NotionPickerInkInstance {
export interface TreePickerInkInstance {
rerender(tree: ReactNode): void;
unmount(): void;
waitUntilExit(): Promise<void>;
}
export interface NotionPickerInkRenderOptions {
stdin?: NotionPickerTuiIo['stdin'];
stdout: NotionPickerTuiIo['stdout'];
stderr: NotionPickerTuiIo['stderr'];
export interface TreePickerInkRenderOptions {
stdin?: TreePickerTuiIo['stdin'];
stdout: TreePickerTuiIo['stdout'];
stderr: TreePickerTuiIo['stderr'];
exitOnCtrlC: boolean;
patchConsole: boolean;
maxFps: number;
alternateScreen: boolean;
}
function resolveTheme(env: NodeJS.ProcessEnv = process.env): NotionPickerTheme {
function resolveTheme(env: NodeJS.ProcessEnv = process.env): TreePickerTheme {
return env.NO_COLOR || env.TERM === 'dumb' ? NO_COLOR_THEME : COLOR_THEME;
}
export function resolveNotionPickerWidth(columns: number | undefined): number {
export function resolveTreePickerWidth(columns: number | undefined): number {
const resolvedColumns = columns ?? 100;
return Math.max(60, Math.min(120, resolvedColumns - 4));
}
function staleWarningText(warning: string): string {
return warning.includes('stored root_page_ids no longer visible')
? `${warning} - they will be removed if you save`
: warning;
}
function selectedPageCountText(count: number): string {
return `${count} selected ${count === 1 ? 'page' : 'pages'}`;
}
function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
const query = state.search.query.trim().toLocaleLowerCase();
if (!query) {
@ -109,7 +113,7 @@ function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
return node.title.toLocaleLowerCase().includes(query) || node.path.toLocaleLowerCase().includes(query);
}
export function sanitizeNotionPickerTuiError(error: unknown): string {
export function sanitizeTreePickerTuiError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message
.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted-url]')
@ -132,7 +136,7 @@ function truncateText(value: string, width: number): string {
return `${value.slice(0, width - 3)}...`;
}
export function notionPickerCommandForInkInput(
export function treePickerCommandForInkInput(
input: string,
key: InkKey,
search: PickerState['search'],
@ -150,7 +154,7 @@ export function notionPickerCommandForInkInput(
if (key.backspace || key.delete) return 'search-backspace';
if (key.downArrow) return 'cursor-down';
if (key.upArrow) return 'cursor-up';
if (input.length === 1 && input >= ' ' && input !== '\u007f') return { type: 'search-input', value: input };
if (input.length === 1 && input >= ' ' && input !== '') return { type: 'search-input', value: input };
return null;
}
if (key.ctrl === true && input === 'c') return 'quit';
@ -158,54 +162,64 @@ export function notionPickerCommandForInkInput(
if (key.downArrow) return 'cursor-down';
if (key.leftArrow) return 'cursor-left';
if (key.rightArrow) return 'cursor-right';
if (key.return) return 'expand';
if (key.return) return 'save-request';
if (input === ' ') return 'toggle-check';
if (input === '/') return 'search-start';
if (input === 'a') return 'select-all-visible';
if (input === 'n') return 'select-none';
if (input === 's') return 'save-request';
if (input === 'q' || key.escape) return 'quit';
if (key.escape) return 'quit';
return null;
}
function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: NotionPickerTheme }): ReactNode {
function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: TreePickerTheme }): ReactNode {
const node = props.state.byId.get(props.nodeId);
if (!node) return null;
const focused = props.state.cursorId === node.id;
const locked = isAncestorChecked(node.id, props.state.checked, props.state.byId);
const checked = props.state.checked.has(node.id);
const glyph = locked ? '[~]' : checked ? '[×]' : '[ ]';
const children =
const isSelected = checked || locked;
const glyph = isSelected ? '◼' : '◻';
const glyphColor = checked || locked ? props.theme.selected : props.theme.muted;
const childAffordance =
node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : '';
const prefix = `${focused ? '▸' : ' '} ${glyph} ${' '.repeat(node.depth * 2)}`;
const color = focused ? props.theme.active : locked || node.archived ? props.theme.muted : props.theme.text;
const title = truncateText(`${node.title}${children}`, Math.max(10, props.width - prefix.length));
const indent = ' '.repeat(node.depth * 2);
const titleColor = focused ? props.theme.active : props.theme.text;
const inverse = rowMatchesSearch(props.state, node.id);
const prefixWidth = indent.length + 2 + childAffordance.length;
const title = truncateText(node.title, Math.max(10, props.width - prefixWidth));
return (
<Text color={color} strikethrough={node.archived}>
{prefix}
<Text inverse={inverse}>{title}</Text>
<Text>
<Text color={glyphColor}>
{indent}
{glyph}
</Text>
<Text color={titleColor} strikethrough={node.archived} bold={focused}>
{' '}
<Text inverse={inverse}>{title}</Text>
</Text>
{childAffordance.length > 0 ? <Text color={props.theme.muted}>{childAffordance}</Text> : null}
</Text>
);
}
export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
export function TreePickerApp(props: TreePickerAppProps): ReactNode {
const app = useApp();
const [state, setState] = useState(props.initialState);
const stateRef = useRef(state);
const theme = useMemo(() => resolveTheme(props.env), [props.env]);
const visibleIds = visibleNodeIds(state);
const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId));
const reservedRows = state.pendingConfirm === 'mode-switch' ? 9 : 8;
const visibleRows = Math.max(5, Math.min(20, (props.terminalRows ?? 24) - reservedRows));
const reservedRows = state.pendingConfirm === 'save-confirm' ? 10 : 9;
const visibleRows = Math.max(5, Math.min(12, (props.terminalRows ?? 24) - reservedRows));
const rows = windowItems(visibleIds, selectedIndex, visibleRows);
const hiddenAbove = rows.offset;
const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length);
const searchMatchCount = filterTree(state).visibleIds.size;
const width = resolveNotionPickerWidth(props.terminalWidth);
const width = resolveTreePickerWidth(props.terminalWidth);
const showSearch = state.search.editing || state.search.query.trim().length > 0;
const selectedCount = flattenSelection(state.checked, state.byId).length;
const helpText = props.chrome.helpText ?? DEFAULT_TREE_PICKER_HELP_TEXT;
const skipEmptyMessage = props.chrome.skipEmptyMessage ?? DEFAULT_SKIP_EMPTY_MESSAGE;
stateRef.current = state;
@ -234,7 +248,7 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
}, [state.transientHint?.expiresAt]);
useInput((input, key) => {
const command = notionPickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm);
const command = treePickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm);
if (!command) {
return;
}
@ -242,7 +256,7 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
stateRef.current = next;
setState(next);
if (effect === 'save') {
props.onExit({ kind: 'save', rootPageIds: flattenSelection(next.checked, next.byId) });
props.onExit({ kind: 'save', selectedIds: flattenSelection(next.checked, next.byId) });
app.exit();
return;
}
@ -254,39 +268,68 @@ export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
return (
<Box flexDirection="column">
<Text color={theme.active}>Notion pages visible to integration "{props.workspaceLabel}"</Text>
{props.cappedAtCount ? <Text color={theme.warning}>{props.cappedAtCount}-page cap reached - some pages not shown</Text> : null}
{state.preLoadWarnings.map((warning) => (
<Text key={warning} color={theme.warning}>
{staleWarningText(warning)}
</Text>
))}
{showSearch ? (
<Text color={theme.muted}>
/ {state.search.query}
{state.search.editing ? '█' : ''} ({searchMatchCount} matches)
</Text>
) : null}
<Box flexDirection="column">
<Text>
<Text color={theme.active}></Text>
<Text bold> {props.chrome.title}</Text>
</Text>
<Box
flexDirection="column"
borderStyle="single"
borderTop={false}
borderRight={false}
borderBottom={false}
borderColor={theme.active}
paddingLeft={1}
>
<Text color={theme.muted}>{helpText}</Text>
<Text> </Text>
{(props.chrome.subtitleLines ?? []).map((line, idx) => (
<Text key={`subtitle-${idx}`} color={theme.muted}>
{line}
</Text>
))}
{(props.chrome.warningLines ?? []).map((line, idx) => (
<Text key={`chromewarn-${idx}`} color={theme.warning}>
{line}
</Text>
))}
{state.preLoadWarnings.map((warning) => (
<Text key={warning} color={theme.warning}>
{warning}
</Text>
))}
{showSearch ? (
<Text>
<Text color={theme.muted}>/ </Text>
<Text>
{state.search.query}
{state.search.editing ? '█' : ''}
</Text>
<Text color={theme.muted}> ({searchMatchCount} matches)</Text>
</Text>
) : null}
<Text> </Text>
{hiddenAbove > 0 ? <Text color={theme.muted}> {hiddenAbove} more</Text> : null}
{rows.items.map((nodeId) => (
<PickerRow key={nodeId} state={state} nodeId={nodeId} width={width} theme={theme} />
))}
{hiddenBelow > 0 ? <Text color={theme.muted}> {hiddenBelow} more</Text> : null}
{state.pendingConfirm === 'save-confirm' ? (
<Text color={theme.warning}>
{props.chrome.confirmSaveMessage
? props.chrome.confirmSaveMessage(state)
: 'Confirm save? Press Enter to confirm or Escape to go back.'}
</Text>
) : null}
{state.pendingConfirm === 'skip-empty' ? <Text color={theme.warning}>{skipEmptyMessage}</Text> : null}
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
</Box>
{state.pendingConfirm === 'mode-switch' ? (
<Text color={theme.warning}>
Save will switch crawl_mode all_accessible -&gt; selected_roots and limit ingest to{' '}
{selectedPageCountText(selectedCount)}. [y] confirm [esc] back
</Text>
) : null}
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
<Text color={theme.muted}>space toggle · enter expand · / search · a all · n none · s save &amp; exit · q quit</Text>
<Text color={theme.active}></Text>
</Box>
);
}
function renderInk(tree: ReactNode, options: NotionPickerInkRenderOptions): NotionPickerInkInstance {
function renderInk(tree: ReactNode, options: TreePickerInkRenderOptions): TreePickerInkInstance {
return renderInkRuntime(tree, {
stdin: options.stdin as NodeJS.ReadStream | undefined,
stdout: options.stdout as NodeJS.WriteStream,
@ -295,19 +338,24 @@ function renderInk(tree: ReactNode, options: NotionPickerInkRenderOptions): Noti
patchConsole: options.patchConsole,
maxFps: options.maxFps,
alternateScreen: options.alternateScreen,
}) as NotionPickerInkInstance;
}) as TreePickerInkInstance;
}
export async function renderNotionPickerTui(
input: PickerRenderInput,
io: NotionPickerTuiIo,
options: { renderInk?: (tree: ReactNode, options: NotionPickerInkRenderOptions) => NotionPickerInkInstance } = {},
): Promise<PickerRenderResult> {
let result: PickerRenderResult = { kind: 'quit' };
let instance: NotionPickerInkInstance | null = null;
export interface RenderTreePickerOptions {
renderInk?: (tree: ReactNode, options: TreePickerInkRenderOptions) => TreePickerInkInstance;
scriptedModeHint?: string;
}
export async function renderTreePickerTui(
input: TreePickerRenderInput,
io: TreePickerTuiIo,
options: RenderTreePickerOptions = {},
): Promise<TreePickerResult> {
let result: TreePickerResult = { kind: 'quit' };
let instance: TreePickerInkInstance | null = null;
try {
instance = (options.renderInk ?? renderInk)(
<NotionPickerApp
<TreePickerApp
{...input}
terminalRows={(io.stdout as { rows?: number }).rows ?? process.stdout.rows ?? 24}
terminalWidth={io.stdout.columns ?? process.stdout.columns}
@ -323,16 +371,15 @@ export async function renderNotionPickerTui(
exitOnCtrlC: false,
patchConsole: false,
maxFps: 30,
alternateScreen: true,
alternateScreen: false,
},
);
await instance.waitUntilExit();
instance.unmount();
return result;
} catch (error) {
io.stderr.write(
`Notion picker requires a TTY. Use --no-input --notion-root-page-id <UUID> for scripted mode. ${sanitizeNotionPickerTuiError(error)}\n`,
);
const hint = options.scriptedModeHint ?? 'Picker requires a TTY.';
io.stderr.write(`${hint} ${sanitizeTreePickerTuiError(error)}\n`);
return { kind: 'quit' };
}
}

View file

@ -12,7 +12,7 @@ Parsimonious. Stage 3 WUs already loaded `ingest_triage` and handled conflicts t
3. If the system prompt includes `<canonical_pins>`, apply those pins before flagging a same-name or near-duplicate conflict. A pinned `canonicalArtifactKey` keeps the contested name when it is present in the Stage Index; competing variants keep or receive disambiguated names.
4. Sweep both exact-key conflicts and near-duplicate writes. Compare WUs that wrote overlapping SL source names, overlapping wiki keys, the same `tables:` or `sl_refs:` action details, or obviously equivalent topic titles under different wiki keys. Call `stage_diff` to see the actual difference, and use `wiki_read`/`sl_read_source` when two different keys appear to describe the same table, metric, or source-of-truth mapping. If they're the same content, leave one canonical artifact and record the duplicate as subsumed. If they differ per `ingest_triage` rules, apply the correct resolution (rename + capture; election of canonical; silent replace for expression-only re-ingest change; or pinned canonical), then call `emit_conflict_resolution` with the artifact key and decision.
5. For any `wiki_write`, `wiki_remove`, `sl_write_source`, or `sl_edit_source` call you make during reconciliation, include `rawPaths` with only the raw paths that directly caused that reconciliation action.
6. Call `eviction_list()` for deleted raw paths. For each listed artifact, remove it (`sl_delete`, `wiki_remove`) and include the evicted raw path in `rawPaths`. Then call `emit_eviction_decision` with `action: "removed"` for every removed artifact.
6. Call `eviction_list()` for deleted raw paths. For each listed artifact, remove it (`sl_write_source`/`sl_edit_source` with `delete: true` for SL sources, `wiki_remove` for wiki pages) and include the evicted raw path in `rawPaths`. Then call `emit_eviction_decision` with `action: "removed"` for every removed artifact.
7. If the Stage 4 sweep discovers a raw file whose only honest outcome is standalone SQL, wiki-only capture, or a human flag, call `emit_unmapped_fallback` with the raw path, reason, and fallback kind.
8. Use `read_raw_span` to zoom into specific raw files when you need to resolve what two contested measures or wiki pages actually describe.
9. Exit when you've processed every item.

View file

@ -7,7 +7,7 @@ callers: [memory_agent]
# Ingest Triage — conflict classification and resolution
This skill is loaded in two contexts:
- By a Stage 3 WorkUnit agent when `sl_discover` or an `sl_discover` reveals that a prior WU (or a prior sync) already wrote something that overlaps with what the current WU is about to write.
- By a Stage 3 WorkUnit agent when `sl_discover` reveals that a prior WU (or a prior sync) already wrote something that overlaps with what the current WU is about to write.
- By the Stage 4 reconciliation agent for cross-WU sweeps and for eviction decisions.
Apply the rules below before every write that could collide with an existing artifact.
@ -32,7 +32,7 @@ Apply the rules below before every write that could collide with an existing art
| Definitional contradiction | Same name, substantively different formulas (different aggregation, different filters, different columns) | **Rename + capture**: disambiguate ALL variants with suffix derived from the domain (`churn_risk_engagement_based`, `churn_risk_billing_based`) and write a unified wiki page listing every variant with provenance. The contested name does NOT land in the SL. **Always flag.** |
5. **Eviction (Stage 4 only)**: for each entry in `eviction_list()`:
- Remove the artifact (`sl_delete` for SL sources, `wiki_remove` for wiki pages).
- Remove the artifact (`sl_write_source` or `sl_edit_source` with `delete: true` for SL sources, `wiki_remove` for wiki pages).
- Record the removal with `emit_eviction_decision` and `action: "removed"`.
## Why same-ingest vs re-ingest differs

View file

@ -84,7 +84,7 @@ SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
**Required flow before writing any overlay or standalone**:
1. Call `sl_discover(<tableName>)` for each base table you're about to touch. That returns the real columns.
1. Call `sl_discover({ query: "<tableName>" })` for each base table you're about to touch. That returns the real columns.
2. If the table isn't in the manifest, use the warehouse `connectionName`
returned by `discover_data` or the target connection chosen from
`sl_discover`, then call a dialect-appropriate SQL probe with that

View file

@ -20,7 +20,7 @@ Each WorkUnit is either a single Notion page/span or a topical cluster of relate
4. Use `context_evidence_search`, `context_evidence_read`, and `context_evidence_neighbors` to pull supporting chunks when indexed evidence is relevant. Pass `chunkId` and `documentId` values verbatim as returned by the evidence tools.
5. Write durable business knowledge with `wiki_write`. Aim for a small number of high-quality pages per WorkUnit or cluster. Include `rawPaths` with the exact Notion raw files that support each page.
6. When the Notion content defines a reusable dataset, metric, segment, join rule, source-of-truth mapping, or table with explicit columns, load `sl_capture`, discover existing sources first with `sl_discover` or `sl_read_source`, then use `sl_write_source` or `sl_edit_source` only for a confirmed mapped non-Notion target source. Include `rawPaths` with the exact Notion raw files that support the SL action. If no mapped target exists, call `emit_unmapped_fallback` and keep the content wiki-only.
7. For every deleted raw path in the Eviction Set, call `eviction_list`, decide retention, then `context_eviction_decision_write`. Do this even when no wiki write is needed.
7. For every deleted raw path in the Eviction Set, call `eviction_list`, decide retention, then `emit_eviction_decision`. Do this even when no wiki write is needed.
## What To Capture
@ -99,6 +99,6 @@ SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
## Tools
Allowed: `read_raw_file`, `read_raw_span`, `wiki_search`, `wiki_read`, `wiki_write`, `discover_data`, `entity_details`, `sql_execution`, `sl_discover`, `sl_read_source`, `sl_write_source`, `sl_edit_source`, `sl_validate`, `context_evidence_search`, `context_evidence_read`, `context_evidence_neighbors`, `emit_unmapped_fallback`, `eviction_list`, `context_eviction_decision_write`.
Allowed: `read_raw_file`, `read_raw_span`, `wiki_search`, `wiki_read`, `wiki_write`, `discover_data`, `entity_details`, `sql_execution`, `sl_discover`, `sl_read_source`, `sl_write_source`, `sl_edit_source`, `sl_validate`, `context_evidence_search`, `context_evidence_read`, `context_evidence_neighbors`, `emit_unmapped_fallback`, `eviction_list`, `emit_eviction_decision`.
Not allowed: `context_candidate_write`, `context_candidate_mark`.

View file

@ -1,6 +1,6 @@
---
name: sl
description: KTX's semantic layer — a structured catalog of sources (tables/views), measures, joins, and segments expressed as YAML. Covers the schema and how to query it via `semantic_query`. Use when the task involves querying pre-defined metrics (ARR, churn, retention, LTV, MAU) or reading SL source YAML to understand the catalog. Capture is handled by the `sl_capture` skill (memory-agent only).
description: KTX's semantic layer — a structured catalog of sources (tables/views), measures, joins, and segments expressed as YAML. Covers the schema and how to query it via `sl_query`. Use when the task involves querying pre-defined metrics (ARR, churn, retention, LTV, MAU) or reading SL source YAML to understand the catalog. Capture is handled by the `sl_capture` skill (memory-agent only).
---
# Semantic Layer
@ -9,7 +9,7 @@ KTX's semantic layer (SL) is a structured catalog. Each **source** represents a
This skill covers two parts:
- **Part 1** — Schema reference (what an SL source looks like).
- **Part 2** — Querying via `semantic_query`.
- **Part 2** — Querying via `sl_query`.
Capture (when and how to add new patterns to the SL) is a separate concern handled by the memory-agent — see the `sl_capture` skill if you are running in capture mode. The research agent **reads** and **queries** the SL via the tools described here; it does not write to it.
@ -162,7 +162,7 @@ segments:
description: Orders that were paid and not refunded
```
Named, reusable boolean predicates scoped to one source. Reference by bare name in a measure's `segments: []`, or by dotted form `source.segment_name` in a `semantic_query`. Segments are predicates only — they are NOT selectable as dimensions. If you need to group by the predicate, add a `columns[]` entry instead.
Named, reusable boolean predicates scoped to one source. Reference by bare name in a measure's `segments: []`, or by dotted form `source.segment_name` in an `sl_query`. Segments are predicates only — they are NOT selectable as dimensions. If you need to group by the predicate, add a `columns[]` entry instead.
### Cross-references with the wiki
@ -170,11 +170,11 @@ The reverse edge (wiki pages that cite this source) is derived automatically fro
---
## Part 2 — Querying via `semantic_query`
## Part 2 — Querying via `sl_query`
The `semantic_query` tool generates correct SQL from a structured query. It handles joins, fan-out prevention, aggregation correctness, and filter classification automatically. Prefer it over writing raw SQL whenever the SL has the relevant sources.
The `sl_query` tool generates correct SQL from a structured query. It handles joins, fan-out prevention, aggregation correctness, and filter classification automatically. Prefer it over writing raw SQL whenever the SL has the relevant sources.
### When to prefer semantic_query over raw SQL
### When to prefer sl_query over raw SQL
- A pre-defined measure already exists (`source.measure_name` appears in the catalog).
- The question combines fields from multiple sources — the engine resolves the join path automatically.
@ -189,15 +189,12 @@ Use raw SQL (`sql_execution`) only when:
```json
{
"connectionId": "uuid-of-the-connection",
"reasoning": "Brief note on what this query analyzes",
"query": {
"measures": ["orders.total_revenue", "sum(orders.amount)"],
"dimensions": ["customers.segment", { "field": "orders.created_at", "granularity": "month" }],
"filters": ["orders.status != 'cancelled'", "orders.total_revenue > 10000"],
"segments": ["orders.paid_non_refunded"],
"order_by": [{ "field": "orders.created_at", "direction": "desc" }],
"limit": 1000
}
"measures": ["orders.total_revenue", "sum(orders.amount)"],
"dimensions": ["customers.segment", { "field": "orders.created_at", "granularity": "month" }],
"filters": ["orders.status != 'cancelled'", "orders.total_revenue > 10000"],
"segments": ["orders.paid_non_refunded"],
"order_by": [{ "field": "orders.created_at", "direction": "desc" }],
"limit": 1000
}
```

View file

@ -63,7 +63,7 @@ Preferred:
- name: total_revenue
expr: sum(amount)
```
Callers filter `region = 'US'` at `semantic_query` time.
Callers filter `region = 'US'` at query time.
**Bake constants in only when the filter has named business meaning that won't change** (`enterprise_arr` for a contractually defined tier), cannot be expressed via the source's dimensions, or comes from a regulated/fixed list.
@ -100,7 +100,7 @@ measures:
**Extract repeated filter bundles into named segments.** If the same predicate appears on multiple measures of the same source, lift it to a `segments[]` entry and have each measure reference it. One edit updates every measure that depends on it.
**Never write a standalone file on a manifest-backed name.** If `sl_discover({ tableName })` finds an existing schema for that name, you MUST write an overlay (`name:` + `measures:`/`segments:`/`descriptions:` only — no `sql:`, `table:`, `grain:`, `columns:`, `joins:`). A standalone with `sql:` or `table:` on a manifest-backed name clobbers the inherited columns and joins; `sl_write_source` and `sl_validate` both reject this shape with a clear fix hint. Always run `sl_discover` before your first write on any existing name.
**Never write a standalone file on a manifest-backed name.** If `sl_discover({ query: "<table-or-source-name>" })` finds an existing schema for that name, you MUST write an overlay (`name:` + `measures:`/`segments:`/`descriptions:` only — no `sql:`, `table:`, `grain:`, `columns:`, `joins:`). A standalone with `sql:` or `table:` on a manifest-backed name clobbers the inherited columns and joins; `sl_write_source` and `sl_validate` both reject this shape with a clear fix hint. Always run `sl_discover` before your first write on any existing name.
**Prefer overlay decomposition over standalone SQL sources.** Before reaching for `source_type: sql`, check whether the metric decomposes into measures on existing overlays (including cross-source derived measures). Use `source_type: sql` only when:
- The metric requires per-user/per-entity derivation that cannot be expressed as a single `expr` (e.g., `EXISTS` over a time-windowed subset), OR
@ -209,10 +209,10 @@ SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
## Tool sequence
1. `sl_discover` — see what source files exist.
2. `sl_discover({ tableName })` — **REQUIRED before the first write on any name**. Shows columns/joins/grain from the manifest. If the call returns a schema, you MUST write an overlay, not a standalone. Skipping this is the #1 cause of accidentally shadowing the manifest.
3. `sl_read_source({ sourceName })` — read the raw YAML before editing.
4. For modifications: `sl_edit_source({ sourceName, old_string, new_string })` with exact-string replacements. `old_string` must match exactly and be unique in the file.
5. For new sources or full rewrites: `sl_write_source({ sourceName, content })` with the full YAML content.
2. `sl_discover({ query: "<table-or-source-name>" })` — **REQUIRED before the first write on any name**. Shows columns/joins/grain from the manifest. If the call returns a schema, you MUST write an overlay, not a standalone. Skipping this is the #1 cause of accidentally shadowing the manifest.
3. `sl_read_source({ connectionId, sourceName })` — read the raw YAML before editing.
4. For modifications: `sl_edit_source({ connectionId, sourceName, yaml_edits: [{ oldText, newText, reason }] })` with exact-string replacements. `oldText` must match exactly and be unique in the file.
5. For new sources or full rewrites: `sl_write_source({ connectionId, sourceName, source })` with the full structured source definition.
6. For join discovery: use `sql_execution({connectionName: "warehouse", sql: "SELECT count(*) FROM public.orders o JOIN public.customers c ON c.id = o.customer_id LIMIT 20"})` with the target warehouse connection name and dialect-correct table names to verify the join key exists in both tables and assess cardinality before declaring the join.
7. Cross-reference knowledge: author the edge once on the **wiki** side via `sl_refs: [source_name]` in the page's front-matter. The reverse edge (wiki pages that cite an SL source) is derived automatically by the reconciler — do not add a `knowledge_refs:` field to SL YAMLs.
8. `sl_validate` — run after writing or editing to surface schema issues, duplicate measure names, and cross-source validation errors. Read-only; the writes are already committed (the squash-at-end flow will collapse them into one commit).
@ -235,13 +235,21 @@ Existing index: `orders [measures=0, joins=0] — candidate for enrichment`.
```
sl_discover()
→ orders.yaml does not exist yet
sl_discover({ tableName: "orders" })
sl_discover({ query: "orders" })
→ see grain, columns, no current overlay
sl_write_source({
connectionId: "warehouse",
sourceName: "orders",
content: "name: orders\nmeasures:\n - name: avg_order_value\n expr: avg(amount)\n description: Mean order transaction amount — filter by product_category at query time\n"
source: {
name: "orders",
measures: [{
name: "avg_order_value",
expr: "avg(amount)",
description: "Mean order transaction amount - filter by product_category at query time"
}]
}
})
sl_validate()
sl_validate({ connectionId: "warehouse" })
→ clean
```
@ -258,16 +266,17 @@ Current user: "Wait, by 'active' I mean users who have placed an order in the la
The existing `users.active_count` measure is wrong by the new definition.
```
sl_read_source({ sourceName: "users" })
sl_read_source({ connectionId: "warehouse", sourceName: "users" })
→ see the wrong measure
sl_edit_source({
connectionId: "warehouse",
sourceName: "users",
yaml_edits: [{
oldText: " - name: active_count\n expr: \"count(*)\"\n filter: \"last_login_at > now() - interval '30 days'\"\n description: Users who logged in within the last 30 days",
newText: " - name: active_count\n expr: \"count(distinct case when last_order_at > now() - interval '30 days' then user_id end)\"\n description: Users with at least one order in the last 30 days"
}]
})
sl_validate()
sl_validate({ connectionId: "warehouse" })
```
If you only added a new measure, the old incorrect `active_count` would stay and future queries would keep answering the wrong question.
@ -277,7 +286,7 @@ If you only added a new measure, the old incorrect `active_count` would stay and
Prior turn: user asked to correlate LTV with protocol count; assistant joined `fct_orders` with `fct_mau_multiprotocol` on `admin_user_id` in raw SQL.
```
sl_read_source({ sourceName: "fct_orders" })
sl_read_source({ connectionId: "warehouse", sourceName: "fct_orders" })
→ no joins section yet
sql_execution({
connectionName: "warehouse",
@ -285,13 +294,14 @@ sql_execution({
})
→ confirms cardinality (many orders per MAU row = many_to_one)
sl_edit_source({
connectionId: "warehouse",
sourceName: "fct_orders",
yaml_edits: [{
oldText: "measures:",
newText: "joins:\n - to: fct_mau_multiprotocol\n on: admin_user_id = fct_mau_multiprotocol.admin_user_id\n relationship: many_to_one\nmeasures:"
}]
})
sl_validate()
sl_validate({ connectionId: "warehouse" })
```
Always verify joins with `sql_execution` before adding them.

View file

@ -31,7 +31,7 @@ Do NOT capture:
- Temporary instructions scoped to the current chat.
- Ad-hoc formatting preferences.
- Information already present in the semantic layer (column names, join paths, measure formulas — those belong in SL).
- **Query results, snapshots, or time-bounded benchmark tables.** Numbers go stale; pasting "Oct 2025: 25%, Nov 2025: 19.9%, …" creates misinformation as soon as new data lands. Reference the SL source by name (`sl_refs`) and let future queries pull live data — the wiki captures the *rule* (definition, exclusion, segmentation), the SL source captures the *measure*, and `semantic_query` captures the *current values*.
- **Query results, snapshots, or time-bounded benchmark tables.** Numbers go stale; pasting "Oct 2025: 25%, Nov 2025: 19.9%, …" creates misinformation as soon as new data lands. Reference the SL source by name (`sl_refs`) and let future query tools pull live data — the wiki captures the *rule* (definition, exclusion, segmentation), the SL source captures the *measure*, and query execution captures the *current values*.
- **Interpretive narrative tied to a specific snapshot** ("M1 retention degraded sharply from Dec 2025"). The observation is anchored to data that will move; the actionable convention (e.g., "always exclude in-progress cohorts") may be worth capturing on its own, but the snapshot-specific commentary is not.
If nothing is worth capturing, respond without calling any tool.
@ -136,7 +136,7 @@ wiki_search({ query: "refund revenue paid orders" })
→ returns `revenue-definition` (related — defines paid-orders filter)
sl_discover({ query: "refund rate" })
→ returns fct_orders (score 0.08), fct_gaap_revenue (0.06)
sl_read_source({ sourceName: "fct_orders" })
sl_read_source({ connectionId: "warehouse", sourceName: "fct_orders" })
→ confirms amount_refunded_dollars and transaction_amount_dollars exist
wiki_write({
key: "refund-rate-definition",

View file

@ -40,6 +40,8 @@ describe('AgentRunnerService.runLoop', () => {
it('passes systemPrompt, userPrompt, tools, and step budget through to generateText', async () => {
(generateText as any).mockResolvedValue({ text: 'ok', toolCalls: [], steps: [] });
const repairHandler = vi.fn();
llmProvider.repairToolCallHandler.mockReturnValueOnce(repairHandler);
const tools = { noop: { description: 'noop', inputSchema: {}, execute: vi.fn() } };
await runner.runLoop({
modelRole: 'candidateExtraction',
@ -59,7 +61,9 @@ describe('AgentRunnerService.runLoop', () => {
expect(call.tools).toEqual(tools);
expect(call.stopWhen).toBe(17);
expect(call.temperature).toBe(0);
expect(call.experimental_repairToolCall).toBe(repairHandler);
expect(llmProvider.getModel).toHaveBeenCalledWith('candidateExtraction');
expect(llmProvider.repairToolCallHandler).toHaveBeenCalledWith({ source: 'ktx-agent-runner' });
});
it('returns stopReason=natural when the loop completes without error', async () => {

View file

@ -73,6 +73,9 @@ export class AgentRunnerService {
temperature: 0,
stopWhen: stepCountIs(params.stepBudget),
experimental_telemetry: this.deps.telemetry?.createTelemetry(params.telemetryTags),
experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({
source: params.telemetryTags.operationName ?? 'ktx-agent-runner',
}),
messages: built.messages,
tools: built.tools as Record<string, Tool>,
onStepFinish: async () => {

View file

@ -695,7 +695,8 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
await params.toolSet.emit_unmapped_fallback.execute(
{
rawPath: 'a.yml',
reason: 'semantic_not_representable',
reason: 'parse_error',
clarification: 'semantic_not_representable',
fallback: 'flagged',
},
{ toolCallId: 'fallback-1', messages: [] },
@ -954,6 +955,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
{
rawPath: 'a.yml',
reason: 'conversion_metric_unsupported',
detail: expect.stringContaining('conversion metric'),
fallback: 'flagged',
},
],
@ -1006,7 +1008,8 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
await params.toolSet.emit_unmapped_fallback.execute(
{
rawPath: 'cards/untranslated.json',
reason: 'metabase_sql_untranslated',
reason: 'parse_error',
clarification: 'metabase_sql_untranslated',
fallback: 'flagged',
},
{ toolCallId: 'fallback-1', messages: [] },
@ -1053,7 +1056,8 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
unmappedFallbacks: [
{
rawPath: 'cards/untranslated.json',
reason: 'metabase_sql_untranslated',
reason: 'parse_error',
detail: expect.stringContaining('metabase_sql_untranslated'),
fallback: 'flagged',
},
],

View file

@ -37,7 +37,9 @@ export type UnmappedFallbackReason =
| 'multiple_table_references'
| 'unsupported_dialect'
| 'parse_error'
| 'missing_target_table';
| 'missing_target_table'
| 'cumulative_metric_unsupported'
| 'conversion_metric_unsupported';
export interface UnmappedFallbackRecord {
rawPath: string;

View file

@ -182,6 +182,30 @@ describe('reconciliation emit tools', () => {
]);
});
it('records MetricFlow-specific unsupported fallback reasons', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitUnmappedFallbackTool({
stageIndex,
allowedPaths: new Set(['metrics/conversion.yml']),
});
const output = await executeTool(tool, {
rawPath: 'metrics/conversion.yml',
reason: 'conversion_metric_unsupported',
fallback: 'flagged',
});
expect(output).toContain('conversion metric');
expect(stageIndex.unmappedFallbacks).toEqual([
{
rawPath: 'metrics/conversion.yml',
reason: 'conversion_metric_unsupported',
detail: expect.stringContaining('conversion metric'),
fallback: 'flagged',
},
]);
});
it('rejects unmapped fallback decisions for raw paths outside the allowed set', async () => {
const stageIndex = makeStageIndex();
const tool = createEmitUnmappedFallbackTool({

View file

@ -17,6 +17,8 @@ const unmappedFallbackReasonSchema = z.enum([
'unsupported_dialect',
'parse_error',
'missing_target_table',
'cumulative_metric_unsupported',
'conversion_metric_unsupported',
]);
function sameUnmappedFallback(left: UnmappedFallbackRecord, right: UnmappedFallbackRecord): boolean {
@ -47,6 +49,10 @@ function canonicalDetail(reason: UnmappedFallbackReason, tableRef: string | unde
return `${tableClause} uses a SQL dialect that is not yet supported.`;
case 'parse_error':
return `${tableClause} could not be parsed.`;
case 'cumulative_metric_unsupported':
return `${tableClause} is a cumulative metric, which is not yet supported as a first-class semantic-layer primitive.`;
case 'conversion_metric_unsupported':
return `${tableClause} is a conversion metric, which is not yet supported as a first-class semantic-layer primitive.`;
}
}

View file

@ -51,6 +51,6 @@ describe('eviction_list tool', () => {
deletedRawPaths: [],
});
expect(tool.description).toContain('context_eviction_decision_write');
expect(tool.description).toContain('emit_eviction_decision');
});
});

View file

@ -12,7 +12,7 @@ export interface EvictionListDeps {
export function createEvictionListTool(deps: EvictionListDeps) {
return tool({
description:
'List every artifact that the most recent completed sync produced from a now-deleted raw file. Remove each listed artifact and record the decision with context_eviction_decision_write so the ingest report lists every deleted-source decision.',
'List every artifact that the most recent completed sync produced from a now-deleted raw file. Remove each listed artifact and record the decision with emit_eviction_decision so the ingest report lists every deleted-source decision.',
inputSchema: z.object({}),
execute: async () => {
if (deps.deletedRawPaths.length === 0) {

View file

@ -28,7 +28,7 @@ const WRITE_TOOL_NAMES = new Set([
]);
export const VERIFICATION_LEDGER_PROMPT = `<pre_write_verification>
Before any write-capable tool call (wiki_write, wiki_remove, sl_write_source, sl_edit_source, emit_unmapped_fallback), call record_verification_ledger.
Before any durable wiki, semantic-layer, or unmapped-fallback write (wiki_write, wiki_remove, sl_write_source, sl_edit_source, emit_unmapped_fallback), call record_verification_ledger.
The ledger is a model-authored checkpoint, not a deterministic parser gate. Summarize the verification protocol from the loaded skill, list identifiers verified with discover_data/entity_details/sql_execution, and list anything intentionally left unverified. If the write contains no warehouse identifiers, say that explicitly.
If a write tool returns verification_ledger_required, complete the ledger and retry the write.
</pre_write_verification>`;

View file

@ -4,6 +4,10 @@ import { generateText, Output, type FlexibleSchema, type ToolSet } from 'ai';
type GenerateTextInput = Parameters<typeof generateText>[0];
type GenerateTextFn = (input: GenerateTextInput) => Promise<{ text?: string; output?: unknown }>;
function hasTools(tools: ToolSet): boolean {
return Object.keys(tools).length > 0;
}
interface GenerateKtxTextInput {
llmProvider: KtxLlmProvider;
role: KtxModelRole;
@ -30,6 +34,13 @@ export async function generateKtxText(input: GenerateKtxTextInput): Promise<stri
temperature: input.temperature ?? 0,
messages: built.messages,
tools: built.tools as ToolSet,
...(hasTools(built.tools as ToolSet)
? {
experimental_repairToolCall: input.llmProvider.repairToolCallHandler({
source: `ktx-${input.role}`,
}),
}
: {}),
});
if (typeof result.text !== 'string') {
throw new Error('KTX LLM text generation returned no text');
@ -52,6 +63,13 @@ export async function generateKtxObject<TOutput, TSchema>(
temperature: input.temperature ?? 0,
messages: built.messages,
tools: built.tools as ToolSet,
...(hasTools(built.tools as ToolSet)
? {
experimental_repairToolCall: input.llmProvider.repairToolCallHandler({
source: `ktx-${input.role}`,
}),
}
: {}),
output: Output.object({
schema: input.schema as FlexibleSchema<TOutput>,
}),

View file

@ -53,7 +53,7 @@ export class SlDiscoverTool extends BaseSemanticLayerTool<typeof slDiscoverInput
return `<purpose>
Discover available semantic layer sources, columns, measures, and joins.
When called without a connectionId, discovers sources across ALL data sources grouped by data source name and ID.
Use this to understand what data is available before writing a semantic_query.
Use this to understand what data is available before querying through the semantic layer.
</purpose>
<when_to_use>

View file

@ -36,7 +36,7 @@ Use this when you need to understand how a source is built — e.g., before edit
<when_not_to_use>
- To discover what sources/measures/dimensions are available for querying use sl_discover instead
- To query data use semantic_query or create_widget with slQuery
- To query data use the semantic-layer query surface (\`sl_query\` in MCP)
</when_not_to_use>`;
}