ktx/packages/cli/test/status-project.test.ts
Andrey Avtomonov 494618ab14
feat: add codex llm backend for ktx runtime work (#253)
* feat: add codex sdk runner foundation

* feat: parse codex runtime events

* feat: expose codex runtime mcp tools

* feat: add codex llm runtime

* feat: wire codex llm backend

* test: avoid Array.fromAsync in codex runner test

* docs: document codex llm backend

* fix: tighten codex runtime config ownership

* fix: use codex sdk env and thread options

* fix: parse codex sdk event shapes

* test: add codex backend live smoke

* docs: clarify codex backend isolation

* fix: drive codex loop metrics from mcp events

* fix: enforce codex local step budget

* docs: disclose codex isolation limits

* fix: count all codex agent steps and stream step callbacks live

The agent-loop step budget only counted completed mcp_tool_call items, so
built-in command_execution steps (which the public Codex SDK/CLI surface can
still expose) never decremented the budget, letting ingest/reconciliation run
past stepBudget until Codex stopped on its own. onStepFinish was also replayed
only after the whole stream drained, so live work_unit_step / reconciliation
progress appeared stuck until the Codex process exited.

collectEvents is now the single live step accumulator: it counts every
completed agent-action item via a shared isCompletedAgentStep predicate
(command_execution, mcp_tool_call, file_change, web_search), fires onStepFinish
as each step completes, and enforces the budget on that broader count. A
no-tool turn still counts as one step. toolFailures stays MCP-specific, since a
non-zero command exit is normal agent exploration, not a loop failure.

* test: align ingest llm-guard assertions with codex backend

The skip-llm ingest guard message now lists codex as a valid backend and
mentions a Claude Code/Codex session plus a codex setup hint, but this slow
suite test still asserted the pre-codex wording. Update it to match the
production message (already covered by the local-bundle-runtime unit test) and
add the codex setup-line assertion.

* fix: treat codex error:null tool calls as success

The Codex SDK serializes error: null on successful mcp_tool_call items, so
the failure check (item.error !== undefined) flagged every successful tool
call as failed with the empty-payload default "Codex turn failed". This
killed every ingest work unit under the codex backend before it could
produce a patch.

Key on status === 'failed' (authoritative, always set) and only treat a
populated error object as a failure. Add a regression test built from a
verbatim real-SDK event capture.

* fix: default codex backend to gpt-5.5 and report real probe errors

The previous default gpt-5.3-codex is an API-key-only model that the OpenAI
API rejects under ChatGPT-account (subscription) auth, so codex status/setup
failed with a misleading "authentication is not usable" message even though
auth was fine.

- Default codex model is now gpt-5.5 (works on both subscription and API-key
  auth); the curated setup picker offers gpt-5.5 / gpt-5.4 / gpt-5.4-mini and
  keeps free-form entry for account-specific ids (e.g. gpt-5.3-codex-spark).
- runCodexAuthProbe now distinguishes "model not available" from an auth
  failure and surfaces the real API error: collectEvents retains stream
  events when the SDK throws on a non-zero exit, and the API error JSON
  envelope is unwrapped to its human-readable message.
- The Codex isolation warning now renders inside the clack setup frame.
- Docs updated to gpt-5.5 with a note that *-codex ids require API-key auth.

* fix: require llm.models.default in status and match codex probe remediation

Status reported a project ready when a non-none LLM backend was configured
without llm.models.default, but the runtime (resolveModelSlots) hard-requires
it, so ingest/scan/memory threw after `ktx status` said the project was usable.
buildLlmStatus now fails for any non-none backend missing models.default and no
longer invents a fallback model for claude-code/codex.

Codex probe failures now carry a category-matched fix: a model-access failure
steers the user at llm.models.default instead of the auth/install remediation.
runCodexAuthProbe returns the fix and status consumes it; the message stays
self-sufficient so setup output is unchanged.

Docs: README now lists the codex backend and local Codex auth; ktx-setup.mdx
states --llm-model only accepts codex/default or gpt-*/codex-* ids.

Repaired four doctor fixtures that configured a backend without models.default
(the now-correctly-blocked config) and added coverage for the new behavior.
2026-06-02 13:57:11 +02:00

743 lines
25 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js';
import type { KtxLocalProject } from '../src/context/project/project.js';
import {
buildLocalStatsStatus,
buildProjectStatus,
renderProjectStatus,
} from '../src/status-project.js';
function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
return {
projectDir: '/work/proj',
configPath: '/work/proj/ktx.yaml',
config,
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore: {} as KtxLocalProject['fileStore'],
};
}
function withEmbeddings(
config: KtxProjectConfig,
embeddings: KtxProjectConfig['ingest']['embeddings'],
): KtxProjectConfig {
return {
...config,
ingest: { ...config.ingest, embeddings },
scan: { ...config.scan, enrichment: { ...config.scan.enrichment, embeddings } },
};
}
function withClaudeCodeLlm(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
llm: {
...config.llm,
provider: { backend: 'claude-code' },
models: { ...config.llm.models, default: 'sonnet' },
},
};
}
function withCodexLlm(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
llm: {
...config.llm,
provider: { backend: 'codex' },
models: { ...config.llm.models, default: 'gpt-5.5' },
},
};
}
function baseProjectConfig(): KtxProjectConfig {
return withClaudeCodeLlm(buildDefaultKtxProjectConfig());
}
const stubClaudeCodeAuthProbe = async () => ({ ok: true as const });
describe('buildProjectStatus embeddings', () => {
it('reports sentence-transformers with explicit base_url as ok', async () => {
const project = projectWithConfig(
withEmbeddings(baseProjectConfig(), {
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { base_url: 'http://my-st:8080', pathPrefix: '' },
}),
);
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
});
expect(status.embeddings).toMatchObject({
backend: 'sentence-transformers',
status: 'ok',
detail: 'service: http://my-st:8080',
});
});
it('reports sentence-transformers with omitted base_url as managed daemon (ok)', async () => {
const project = projectWithConfig(
withEmbeddings(baseProjectConfig(), {
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
}),
);
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
});
expect(status.embeddings).toMatchObject({
backend: 'sentence-transformers',
status: 'ok',
detail: 'managed local embeddings daemon',
});
expect(status.verdictReason).not.toMatch(/embedding credentials missing/);
});
it('reports sentence-transformers with empty base_url string as managed daemon (ok)', async () => {
const project = projectWithConfig(
withEmbeddings(baseProjectConfig(), {
backend: 'sentence-transformers',
model: 'all-MiniLM-L6-v2',
dimensions: 384,
sentenceTransformers: { base_url: '', pathPrefix: '' },
}),
);
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
});
expect(status.embeddings).toMatchObject({
backend: 'sentence-transformers',
status: 'ok',
detail: 'managed local embeddings daemon',
});
});
it('reports openai backend with missing key as warn', async () => {
const project = projectWithConfig(
withEmbeddings(baseProjectConfig(), {
backend: 'openai',
model: 'text-embedding-3-small',
dimensions: 1536,
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
}),
);
const status = await buildProjectStatus(project, {
env: {},
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
});
expect(status.embeddings.status).toBe('warn');
expect(status.verdictReason).toMatch(/embedding credentials missing/);
});
});
function withPostgresQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
connections: {
...config.connections,
analytics: {
driver: 'postgres',
url: 'env:ANALYTICS_DATABASE_URL',
context: { queryHistory: { enabled: true } },
} as KtxProjectConfig['connections'][string],
},
};
}
function withSnowflakeQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
connections: {
...config.connections,
warehouse: {
driver: 'snowflake',
account: 'EMOVRJS-CZ07756',
warehouse: 'COMPUTE_WH',
database: 'ANALYTICS',
username: 'svc_ktx',
password: 'env:SNOWFLAKE_PASSWORD', // pragma: allowlist secret
context: { queryHistory: { enabled: true } },
} as KtxProjectConfig['connections'][string],
},
};
}
function withBigQueryQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
connections: {
...config.connections,
bq: {
driver: 'bigquery',
credentials_json: 'env:BQ_CREDENTIALS_JSON',
context: { queryHistory: { enabled: true } },
} as KtxProjectConfig['connections'][string],
},
};
}
function withMysqlQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
connections: {
...config.connections,
legacy: {
driver: 'mysql',
host: 'db.example.com',
database: 'analytics',
username: 'svc',
password: 'env:MYSQL_PASSWORD', // pragma: allowlist secret
context: { queryHistory: { enabled: true } },
} as KtxProjectConfig['connections'][string],
},
};
}
function fakeStatusRunner(
dialect: 'postgres' | 'snowflake' | 'bigquery',
catalogName: string,
) {
return {
dialect,
catalogName,
async run() {
return { warnings: [], info: [] };
},
formatSuccessDetail(result: unknown) {
const typed = result as { warnings: string[]; info?: string[]; pgServerVersion?: string };
const info = typed.info && typed.info.length > 0 ? `; ${typed.info.join('; ')}` : '';
const base =
dialect === 'postgres'
? `pg_stat_statements ready (${typed.pgServerVersion ?? 'PostgreSQL 16.4'})`
: `${catalogName} ready`;
return { detail: `${base}${info}`, warnings: typed.warnings };
},
fixAdvice(error: unknown) {
return {
failHeadline: error instanceof Error ? error.message : String(error),
remediation: 'Fix query-history grants.',
};
},
};
}
describe('buildProjectStatus query history dispatch', () => {
it('runs the shared probe for snowflake connections', async () => {
let probeCalls = 0;
const runner = fakeStatusRunner(
'snowflake',
'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY',
);
const project = projectWithConfig(withSnowflakeQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
queryHistoryReadinessProbe: async (input) => {
probeCalls += 1;
expect(input.connectionId).toBe('warehouse');
return {
ok: true,
dialect: 'snowflake',
runner,
result: { warnings: [], info: [] },
};
},
});
expect(probeCalls).toBe(1);
expect(status.queryHistory).toHaveLength(1);
expect(status.queryHistory[0]).toMatchObject({
connection: 'warehouse',
driver: 'snowflake',
dialect: 'snowflake',
status: 'ok',
});
expect(status.queryHistory[0].detail).toMatch(/SNOWFLAKE\.ACCOUNT_USAGE\.QUERY_HISTORY/);
expect(status.queryHistory[0].fix).toBeUndefined();
expect(status.verdict).not.toBe('blocked');
});
it('reports snowflake probe failures with the reader-provided remediation', async () => {
const project = projectWithConfig(withSnowflakeQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
queryHistoryReadinessProbe: async () => ({
ok: false,
dialect: 'snowflake',
runner: {
...fakeStatusRunner('snowflake', 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'),
fixAdvice: () => ({
failHeadline: 'Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY',
remediation: 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ktx;',
}),
},
error: new Error('role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'),
}),
});
expect(status.queryHistory[0]).toMatchObject({
connection: 'warehouse',
driver: 'snowflake',
dialect: 'snowflake',
status: 'fail',
fix: 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ktx;',
});
expect(status.queryHistory[0].detail).not.toMatch(/Set connections.*driver to postgres/);
});
it('runs the bigquery probe for bigquery connections', async () => {
let probeCalls = 0;
const runner = fakeStatusRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT');
const project = projectWithConfig(withBigQueryQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
queryHistoryReadinessProbe: async (input) => {
probeCalls += 1;
expect(input.connectionId).toBe('bq');
return {
ok: true,
dialect: 'bigquery',
runner,
result: { warnings: [], info: [] },
};
},
});
expect(probeCalls).toBe(1);
expect(status.queryHistory[0]).toMatchObject({
connection: 'bq',
driver: 'bigquery',
dialect: 'bigquery',
status: 'ok',
});
expect(status.queryHistory[0].detail).toMatch(/INFORMATION_SCHEMA\.JOBS_BY_PROJECT/);
});
it('fails with an accurate message for drivers without a query history reader', async () => {
const project = projectWithConfig(withMysqlQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
queryHistoryReadinessProbe: async () => {
throw new Error('postgres probe must not run for mysql');
},
});
expect(status.queryHistory).toHaveLength(1);
expect(status.queryHistory[0]).toMatchObject({
connection: 'legacy',
driver: 'mysql',
dialect: 'mysql',
status: 'fail',
detail: 'query history is not supported for driver "mysql"',
});
expect(status.queryHistory[0].fix).toMatch(
/Disable connections\.legacy\.context\.queryHistory/,
);
expect(status.queryHistory[0].fix).not.toMatch(/driver to postgres/);
});
});
describe('buildProjectStatus --fast', () => {
it('skips claude-code probe and Postgres query-history probe', async () => {
let claudeProbeCalls = 0;
let queryHistoryProbeCalls = 0;
const project = projectWithConfig(withPostgresQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
env: { ANALYTICS_DATABASE_URL: 'postgres://example' },
fast: true,
claudeCodeAuthProbe: async () => {
claudeProbeCalls += 1;
return { ok: true };
},
queryHistoryReadinessProbe: async () => {
queryHistoryProbeCalls += 1;
throw new Error('should not be called');
},
});
expect(claudeProbeCalls).toBe(0);
expect(queryHistoryProbeCalls).toBe(0);
expect(status.llm.status).toBe('skipped');
expect(status.llm.detail).toMatch(/--fast/);
expect(status.queryHistory).toHaveLength(1);
expect(status.queryHistory[0]).toMatchObject({
connection: 'analytics',
status: 'skipped',
});
expect(status.verdict).not.toBe('blocked');
});
it('does not call probes lazily when fast and reports skipped in render', async () => {
const project = projectWithConfig(withPostgresQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
env: { ANALYTICS_DATABASE_URL: 'postgres://example' },
fast: true,
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
queryHistoryReadinessProbe: async () => {
throw new Error('should not be called');
},
});
const rendered = renderProjectStatus(status, { verbose: false, useColor: false });
expect(rendered).toContain('auth probe skipped (--fast)');
expect(rendered).toContain('pg_stat_statements probe skipped (--fast)');
});
});
describe('buildProjectStatus codex', () => {
it('reports authenticated local Codex session', async () => {
const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig()));
const status = await buildProjectStatus(project, {
codexAuthProbe: async () => ({ ok: true as const }),
});
expect(status.llm).toMatchObject({
backend: 'codex',
model: 'gpt-5.5',
status: 'ok',
detail: 'local Codex session authenticated',
});
expect(status.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining('Codex backend isolation is limited'),
fix: expect.stringContaining('claude-code'),
}),
]),
);
const rendered = renderProjectStatus(status, { verbose: false, useColor: false });
expect(rendered).toContain('Codex backend isolation is limited');
});
it('skips Codex auth probe with --fast', async () => {
let probeCalls = 0;
const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig()));
const status = await buildProjectStatus(project, {
fast: true,
codexAuthProbe: async () => {
probeCalls += 1;
return { ok: true };
},
});
expect(probeCalls).toBe(0);
expect(status.llm.status).toBe('skipped');
expect(status.llm.detail).toMatch(/--fast/);
});
it('surfaces the probe fix for a model-access failure instead of an auth fix', async () => {
const project = projectWithConfig(withCodexLlm(buildDefaultKtxProjectConfig()));
const status = await buildProjectStatus(project, {
codexAuthProbe: async () => ({
ok: false,
message: 'Codex is authenticated, but the configured model "gpt-5.5" is not available...',
fix: 'Run `codex` to see the models your account supports, then set llm.models.default in ktx.yaml (or rerun `ktx setup`).',
}),
});
expect(status.llm.status).toBe('fail');
expect(status.llm.fix).toContain('llm.models.default');
expect(status.llm.fix).not.toContain('Authenticate Codex');
});
});
describe('buildProjectStatus llm models.default requirement', () => {
function withBackendNoModel(
backend: KtxProjectConfig['llm']['provider']['backend'],
): KtxProjectConfig {
const config = buildDefaultKtxProjectConfig();
return {
...config,
llm: { ...config.llm, provider: { backend }, models: {} },
};
}
it('fails codex without llm.models.default and never probes', async () => {
let probeCalls = 0;
const project = projectWithConfig(withBackendNoModel('codex'));
const status = await buildProjectStatus(project, {
codexAuthProbe: async () => {
probeCalls += 1;
return { ok: true };
},
});
expect(probeCalls).toBe(0);
expect(status.llm.status).toBe('fail');
expect(status.llm.detail).toContain('llm.models.default');
expect(status.verdict).toBe('blocked');
});
it('fails claude-code without llm.models.default and never probes', async () => {
let probeCalls = 0;
const project = projectWithConfig(withBackendNoModel('claude-code'));
const status = await buildProjectStatus(project, {
claudeCodeAuthProbe: async () => {
probeCalls += 1;
return { ok: true };
},
});
expect(probeCalls).toBe(0);
expect(status.llm.status).toBe('fail');
expect(status.llm.detail).toContain('llm.models.default');
expect(status.verdict).toBe('blocked');
});
it('fails anthropic without llm.models.default even when the key is set', async () => {
const config = withBackendNoModel('anthropic');
const project = projectWithConfig({
...config,
llm: {
...config.llm,
provider: { backend: 'anthropic', anthropic: { api_key: 'env:ANTHROPIC_API_KEY' } }, // pragma: allowlist secret
models: {},
},
});
const status = await buildProjectStatus(project, {
env: { ANTHROPIC_API_KEY: 'sk-test' }, // pragma: allowlist secret
});
expect(status.llm.status).toBe('fail');
expect(status.llm.detail).toContain('llm.models.default');
expect(status.verdict).toBe('blocked');
});
});
describe('buildLocalStatsStatus', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-status-stats-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
function projectIn(dir: string): KtxLocalProject {
return {
projectDir: dir,
configPath: join(dir, 'ktx.yaml'),
config: baseProjectConfig(),
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore: {} as KtxLocalProject['fileStore'],
};
}
it('returns unavailable when .ktx/db.sqlite is missing', async () => {
const stats = await buildLocalStatsStatus(projectIn(tempDir));
expect(stats.unavailable).toMatch(/no \.ktx\/db\.sqlite/);
expect(stats.ingest.totalCompletedRuns).toBe(0);
expect(stats.projectDir.dbSqliteBytes).toBeNull();
});
it('reads counts from a seeded SQLite DB and walks projectDir folders', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
await mkdir(join(tempDir, '.ktx', 'cache'), { recursive: true });
await writeFile(join(tempDir, '.ktx', 'cache', 'a.bin'), Buffer.alloc(2048));
await mkdir(join(tempDir, 'raw-sources', 'analytics'), { recursive: true });
await writeFile(join(tempDir, 'raw-sources', 'analytics', 'snap.json'), 'x'.repeat(100));
await writeFile(join(tempDir, 'raw-sources', 'analytics', 'snap.bin'), Buffer.alloc(512));
await mkdir(join(tempDir, 'wiki', 'global', 'sub'), { recursive: true });
await writeFile(join(tempDir, 'wiki', 'global', 'one.md'), '# one');
await writeFile(join(tempDir, 'wiki', 'global', 'sub', 'two.md'), '# two');
await mkdir(join(tempDir, 'semantic-layer'), { recursive: true });
await writeFile(join(tempDir, 'semantic-layer', 'orders.yaml'), 'name: orders');
await writeFile(join(tempDir, 'semantic-layer', 'users.yml'), 'name: users');
const dbPath = join(tempDir, '.ktx', 'db.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE local_ingest_reports (
run_id TEXT PRIMARY KEY,
adapter TEXT NOT NULL,
connection_id TEXT NOT NULL,
status TEXT NOT NULL,
completed_at TEXT NOT NULL,
raw_content_hashes_json TEXT NOT NULL,
body_json TEXT NOT NULL
);
INSERT INTO local_ingest_reports VALUES
('r1', 'live-database', 'analytics', 'done', '2026-04-01T10:00:00Z', '{}', '{}'),
('r2', 'live-database', 'analytics', 'done', '2026-05-10T10:00:00Z', '{}', '{}'),
('r3', 'notion', 'docs', 'done', '2026-05-01T10:00:00Z', '{}', '{}'),
('r4', 'notion', 'docs', 'error', '2026-05-02T10:00:00Z', '{}', '{}');
CREATE TABLE knowledge_pages (
path TEXT PRIMARY KEY,
key TEXT NOT NULL,
scope TEXT NOT NULL,
scope_id TEXT,
summary TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL,
search_text TEXT NOT NULL DEFAULT '',
embedding_json TEXT
);
INSERT INTO knowledge_pages VALUES
('a.md', 'a', 'GLOBAL', NULL, '', '', '[]', '', '[0.1,0.2]'),
('b.md', 'b', 'GLOBAL', NULL, '', '', '[]', '', NULL),
('c.md', 'c', 'PROJECT', NULL, '', '', '[]', '', '[0.3,0.4]');
CREATE TABLE local_sl_sources (
connection_id TEXT NOT NULL,
source_name TEXT NOT NULL,
search_text TEXT NOT NULL,
embedding_json TEXT,
content_hash TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (connection_id, source_name)
);
INSERT INTO local_sl_sources VALUES
('analytics', 'orders', '', '[0.1,0.2]', NULL, '2026-05-10T10:00:00Z'),
('analytics', 'users', '', NULL, NULL, '2026-05-10T10:00:00Z');
CREATE TABLE local_sl_dictionary_values (
connection_id TEXT NOT NULL,
source_name TEXT NOT NULL,
column_name TEXT NOT NULL,
value TEXT NOT NULL,
value_lower TEXT NOT NULL,
cardinality INTEGER,
updated_at TEXT NOT NULL,
PRIMARY KEY (connection_id, source_name, column_name, value)
);
INSERT INTO local_sl_dictionary_values VALUES
('analytics', 'orders', 'status', 'open', 'open', 1, '2026-05-10T10:00:00Z'),
('analytics', 'orders', 'status', 'closed', 'closed', 1, '2026-05-10T10:00:00Z');
`);
db.close();
const stats = await buildLocalStatsStatus(projectIn(tempDir));
expect(stats.unavailable).toBeUndefined();
expect(stats.ingest.totalCompletedRuns).toBe(3);
expect(stats.ingest.perConnection).toEqual([
{ connectionId: 'analytics', adapter: 'live-database', lastCompletedAt: '2026-05-10T10:00:00Z' },
{ connectionId: 'docs', adapter: 'notion', lastCompletedAt: '2026-05-01T10:00:00Z' },
]);
expect(stats.wikiPages).toEqual([
{ scope: 'GLOBAL', count: 2, embeddedCount: 1 },
{ scope: 'PROJECT', count: 1, embeddedCount: 1 },
]);
expect(stats.semanticLayer).toEqual([
{
connectionId: 'analytics',
sourceCount: 2,
embeddedSourceCount: 1,
dictionaryValueCount: 2,
},
]);
expect(stats.projectDir.dbSqliteBytes).toBeGreaterThan(0);
expect(stats.projectDir.ktxCacheBytes).toBe(2048);
expect(stats.projectDir.rawSources).toEqual({ fileCount: 2, bytes: 612 });
});
it('tolerates a SQLite DB missing some tables', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
const dbPath = join(tempDir, '.ktx', 'db.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE local_ingest_reports (
run_id TEXT PRIMARY KEY,
adapter TEXT NOT NULL,
connection_id TEXT NOT NULL,
status TEXT NOT NULL,
completed_at TEXT NOT NULL,
raw_content_hashes_json TEXT NOT NULL,
body_json TEXT NOT NULL
);
INSERT INTO local_ingest_reports VALUES
('r1', 'live-database', 'analytics', 'done', '2026-05-10T10:00:00Z', '{}', '{}');
`);
db.close();
const stats = await buildLocalStatsStatus(projectIn(tempDir));
expect(stats.unavailable).toBeUndefined();
expect(stats.ingest.totalCompletedRuns).toBe(1);
expect(stats.wikiPages).toEqual([]);
expect(stats.semanticLayer).toEqual([]);
});
});
describe('renderProjectStatus Local data', () => {
it('renders the Local data section with seeded stats', async () => {
const project = projectWithConfig(baseProjectConfig());
const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe });
status.localStats = {
ingest: {
totalCompletedRuns: 3,
perConnection: [
{ connectionId: 'analytics', adapter: 'live-database', lastCompletedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString() },
],
},
wikiPages: [
{ scope: 'GLOBAL', count: 2, embeddedCount: 2 },
{ scope: 'PROJECT', count: 1, embeddedCount: 0 },
],
semanticLayer: [
{
connectionId: 'analytics',
sourceCount: 12,
embeddedSourceCount: 10,
dictionaryValueCount: 200,
},
],
projectDir: {
dbSqliteBytes: 4096,
ktxCacheBytes: 1_048_576,
rawSources: { fileCount: 5, bytes: 200 },
},
};
const rendered = renderProjectStatus(status, { useColor: false });
expect(rendered).toContain('Local data');
expect(rendered).toContain('Wiki');
expect(rendered).not.toContain('Knowledge');
expect(rendered).toContain('3 completed runs');
expect(rendered).toContain('GLOBAL=2 (2 embedded)');
expect(rendered).toContain('PROJECT=1 (0 embedded)');
expect(rendered).toContain('12 sources (10 embedded) · 200 dictionary values');
expect(rendered).toContain('db=4.00 KiB');
expect(rendered).toContain('cache=1.00 MiB');
expect(rendered).not.toMatch(/wiki=\d+ md/);
expect(rendered).not.toMatch(/semantic-layer=\d+ yaml/);
});
it('renders unavailable note when DB is missing', async () => {
const project = projectWithConfig(baseProjectConfig());
const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe });
status.localStats = {
ingest: { totalCompletedRuns: 0, perConnection: [] },
wikiPages: [],
semanticLayer: [],
projectDir: {
dbSqliteBytes: null,
ktxCacheBytes: 0,
rawSources: { fileCount: 0, bytes: 0 },
},
unavailable: 'no .ktx/db.sqlite yet',
};
const rendered = renderProjectStatus(status, { useColor: false });
expect(rendered).toContain('Local data');
expect(rendered).toContain('no .ktx/db.sqlite yet');
});
});