mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat(cli): add --fast flag and Local data section to ktx status (#198)
Add --fast to skip checks requiring external communication (Claude Code auth probe and Postgres pg_stat_statements probe); skipped checks render as `-` and carry `"status": "skipped"` in JSON output. Always show a new Local data section sourced from .ktx/db.sqlite (ingest run counts and last-completed per connection, knowledge page counts by scope, semantic layer source/dictionary value counts) plus on-disk sizes for .ktx/db.sqlite, .ktx/cache/, raw-sources/, wiki/global/, and semantic-layer/. Wrap the remaining slow probes in a @clack/prompts spinner when stdout is a TTY.
This commit is contained in:
parent
50c7bbc957
commit
1c7131c6c2
6 changed files with 740 additions and 13 deletions
|
|
@ -21,6 +21,7 @@ ktx status [options]
|
|||
| `--json` | Print JSON output | `false` |
|
||||
| `-v`, `--verbose` | Show every check, including passing ones | `false` |
|
||||
| `--validate` | Only validate the `ktx.yaml` schema; skip readiness checks | `false` |
|
||||
| `--fast` | Skip checks that require external communication (Postgres query-history probe, Claude Code auth probe) | `false` |
|
||||
| `--no-input` | Disable interactive terminal input | - |
|
||||
|
||||
## Examples
|
||||
|
|
@ -38,6 +39,9 @@ ktx status --verbose
|
|||
# Validate ktx.yaml without running readiness checks
|
||||
ktx status --validate
|
||||
|
||||
# Skip slow probes (Postgres pg_stat_statements, Claude Code auth)
|
||||
ktx status --fast
|
||||
|
||||
# Check a project from another directory
|
||||
ktx status --project-dir ./analytics
|
||||
```
|
||||
|
|
@ -49,7 +53,16 @@ ktx status --project-dir ./analytics
|
|||
|
||||
For `llm.provider.backend: claude-code`, `ktx status` checks that the local
|
||||
Claude Code session is usable. If auth fails, run the Claude Code CLI login
|
||||
flow, then rerun `ktx status`.
|
||||
flow, then rerun `ktx status`. Use `--fast` to skip this probe (useful in CI
|
||||
or offline contexts); skipped checks render as `-` and carry
|
||||
`"status": "skipped"` in JSON output.
|
||||
|
||||
A `Local data` section summarises what the project has accumulated locally:
|
||||
ingest run counts, last completed timestamp per connection, knowledge page
|
||||
counts by scope, semantic-layer source and dictionary value counts, and the
|
||||
on-disk size of `.ktx/db.sqlite`, `.ktx/cache/`, `raw-sources/`, `wiki/global/`,
|
||||
and `semantic-layer/`. These are read from `.ktx/db.sqlite` and local file
|
||||
stats, and are always shown (they do not require external communication).
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
|||
.option('--json', 'Print JSON output', false)
|
||||
.option('-v, --verbose', 'Show every check, including passing ones', false)
|
||||
.option('--validate', 'Only validate the ktx.yaml schema; skip readiness checks', false)
|
||||
.option('--fast', 'Skip checks that require external communication (DB probes, auth probes)', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(
|
||||
async (
|
||||
options: { json?: boolean; verbose?: boolean; validate?: boolean; input?: boolean },
|
||||
options: { json?: boolean; verbose?: boolean; validate?: boolean; fast?: boolean; input?: boolean },
|
||||
command,
|
||||
) => {
|
||||
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
|
||||
|
|
@ -64,6 +65,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
|
|||
projectDir: resolveCommandProjectDir(command),
|
||||
outputMode: outputMode(options),
|
||||
verbose: options.verbose === true,
|
||||
fast: options.fast === true,
|
||||
...inputMode(options),
|
||||
},
|
||||
context.io,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type KtxDoctorArgs =
|
|||
outputMode: KtxDoctorOutputMode;
|
||||
inputMode?: KtxDoctorInputMode;
|
||||
verbose?: boolean;
|
||||
fast?: boolean;
|
||||
}
|
||||
| {
|
||||
command: 'validate';
|
||||
|
|
@ -619,7 +620,15 @@ export async function runKtxDoctor(
|
|||
return 1;
|
||||
}
|
||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||
const projectStatus = await buildProjectStatus(project, { ...deps, configIssues: validation.issues });
|
||||
const fast = args.fast ?? false;
|
||||
const useSpinner =
|
||||
!fast && args.outputMode === 'plain' && io.stdout.isTTY === true;
|
||||
const projectStatus = await buildProjectStatus(project, {
|
||||
...deps,
|
||||
configIssues: validation.issues,
|
||||
fast,
|
||||
useSpinner,
|
||||
});
|
||||
const verbose = args.verbose ?? false;
|
||||
const toolchainChecks = verbose ? await runSetupChecks() : undefined;
|
||||
if (args.outputMode === 'json') {
|
||||
|
|
|
|||
|
|
@ -1064,7 +1064,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
expect(setup).not.toHaveBeenCalled();
|
||||
expect(doctor).toHaveBeenCalledWith(
|
||||
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled', verbose: false },
|
||||
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled', verbose: false, fast: false },
|
||||
statusIo.io,
|
||||
);
|
||||
expect(statusIo.stderr()).toBe('');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
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 KtxLocalProject, type KtxProjectConfig } from '@ktx/context/project';
|
||||
import { buildProjectStatus } from './status-project.js';
|
||||
import {
|
||||
buildLocalStatsStatus,
|
||||
buildProjectStatus,
|
||||
renderProjectStatus,
|
||||
} from './status-project.js';
|
||||
|
||||
function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
|
||||
return {
|
||||
|
|
@ -124,3 +132,278 @@ describe('buildProjectStatus embeddings', () => {
|
|||
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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildProjectStatus --fast', () => {
|
||||
it('skips claude-code probe and Postgres query-history probe', async () => {
|
||||
let claudeProbeCalls = 0;
|
||||
let pgProbeCalls = 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 };
|
||||
},
|
||||
postgresQueryHistoryProbe: async () => {
|
||||
pgProbeCalls += 1;
|
||||
throw new Error('should not be called');
|
||||
},
|
||||
});
|
||||
|
||||
expect(claudeProbeCalls).toBe(0);
|
||||
expect(pgProbeCalls).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,
|
||||
postgresQueryHistoryProbe: 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('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, '', '', '[]', '', NULL),
|
||||
('b.md', 'b', 'GLOBAL', NULL, '', '', '[]', '', NULL),
|
||||
('c.md', 'c', 'PROJECT', NULL, '', '', '[]', '', NULL);
|
||||
|
||||
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', '', NULL, 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.knowledgePages).toEqual([
|
||||
{ scope: 'GLOBAL', count: 2 },
|
||||
{ scope: 'PROJECT', count: 1 },
|
||||
]);
|
||||
expect(stats.semanticLayer).toEqual([
|
||||
{ connectionId: 'analytics', sourceCount: 2, dictionaryValueCount: 2 },
|
||||
]);
|
||||
expect(stats.projectDir.dbSqliteBytes).toBeGreaterThan(0);
|
||||
expect(stats.projectDir.ktxCacheBytes).toBe(2048);
|
||||
expect(stats.projectDir.rawSources).toEqual({ fileCount: 2, bytes: 612 });
|
||||
expect(stats.projectDir.wikiGlobalMarkdownCount).toBe(2);
|
||||
expect(stats.projectDir.semanticLayerYamlCount).toBe(2);
|
||||
});
|
||||
|
||||
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.knowledgePages).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() },
|
||||
],
|
||||
},
|
||||
knowledgePages: [
|
||||
{ scope: 'GLOBAL', count: 2 },
|
||||
{ scope: 'PROJECT', count: 1 },
|
||||
],
|
||||
semanticLayer: [
|
||||
{ connectionId: 'analytics', sourceCount: 12, dictionaryValueCount: 200 },
|
||||
],
|
||||
projectDir: {
|
||||
dbSqliteBytes: 4096,
|
||||
ktxCacheBytes: 1_048_576,
|
||||
rawSources: { fileCount: 5, bytes: 200 },
|
||||
wikiGlobalMarkdownCount: 7,
|
||||
semanticLayerYamlCount: 3,
|
||||
},
|
||||
};
|
||||
const rendered = renderProjectStatus(status, { useColor: false });
|
||||
expect(rendered).toContain('Local data');
|
||||
expect(rendered).toContain('3 completed runs');
|
||||
expect(rendered).toContain('GLOBAL=2');
|
||||
expect(rendered).toContain('PROJECT=1');
|
||||
expect(rendered).toContain('12 sources · 200 dictionary values');
|
||||
expect(rendered).toContain('db=4.00 KiB');
|
||||
expect(rendered).toContain('cache=1.00 MiB');
|
||||
expect(rendered).toContain('wiki=7 md');
|
||||
expect(rendered).toContain('semantic-layer=3 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: [] },
|
||||
knowledgePages: [],
|
||||
semanticLayer: [],
|
||||
projectDir: {
|
||||
dbSqliteBytes: null,
|
||||
ktxCacheBytes: 0,
|
||||
rawSources: { fileCount: 0, bytes: 0 },
|
||||
wikiGlobalMarkdownCount: 0,
|
||||
semanticLayerYamlCount: 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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { basename } from 'node:path';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import { stat as statAsync, readdir as readdirAsync } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { runClaudeCodeAuthProbe } from '@ktx/context';
|
||||
import type {
|
||||
KtxConfigIssue,
|
||||
|
|
@ -8,6 +10,7 @@ import type {
|
|||
KtxProjectEmbeddingConfig,
|
||||
KtxProjectLlmConfig,
|
||||
} from '@ktx/context/project';
|
||||
import { ktxLocalStateDbPath } from '@ktx/context/project';
|
||||
import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
|
||||
import {
|
||||
formatClaudeCodePromptCachingFix,
|
||||
|
|
@ -24,7 +27,7 @@ import {
|
|||
} from './io/symbols.js';
|
||||
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
|
||||
type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
|
||||
type ProjectStatusLevel = 'ok' | 'warn' | 'fail' | 'skipped';
|
||||
type ProjectVerdict = 'ready' | 'partial' | 'blocked';
|
||||
|
||||
interface ProjectStatusLine {
|
||||
|
|
@ -99,6 +102,42 @@ function hasOwnField(value: Record<string, unknown>, key: string): boolean {
|
|||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
interface LocalStatsIngestPerConnection {
|
||||
connectionId: string;
|
||||
adapter: string;
|
||||
lastCompletedAt: string;
|
||||
}
|
||||
|
||||
interface LocalStatsSemanticLayerEntry {
|
||||
connectionId: string;
|
||||
sourceCount: number;
|
||||
dictionaryValueCount: number;
|
||||
}
|
||||
|
||||
interface LocalStatsKnowledgeEntry {
|
||||
scope: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface LocalStatsProjectDir {
|
||||
dbSqliteBytes: number | null;
|
||||
ktxCacheBytes: number;
|
||||
rawSources: { fileCount: number; bytes: number };
|
||||
wikiGlobalMarkdownCount: number;
|
||||
semanticLayerYamlCount: number;
|
||||
}
|
||||
|
||||
export interface LocalStatsStatus {
|
||||
ingest: {
|
||||
totalCompletedRuns: number;
|
||||
perConnection: LocalStatsIngestPerConnection[];
|
||||
};
|
||||
knowledgePages: LocalStatsKnowledgeEntry[];
|
||||
semanticLayer: LocalStatsSemanticLayerEntry[];
|
||||
projectDir: LocalStatsProjectDir;
|
||||
unavailable?: string;
|
||||
}
|
||||
|
||||
export interface ProjectStatus {
|
||||
projectName: string;
|
||||
projectDir: string;
|
||||
|
|
@ -110,6 +149,7 @@ export interface ProjectStatus {
|
|||
queryHistory: QueryHistoryStatus[];
|
||||
pipeline: PipelineStatus;
|
||||
warnings: WarningItem[];
|
||||
localStats: LocalStatsStatus;
|
||||
verdict: ProjectVerdict;
|
||||
verdictReason: string;
|
||||
nextActions: string[];
|
||||
|
|
@ -152,6 +192,8 @@ async function buildLlmStatus(
|
|||
projectDir: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
claudeCodeAuthProbe?: ClaudeCodeAuthProbe;
|
||||
fast?: boolean;
|
||||
useSpinner?: boolean;
|
||||
},
|
||||
): Promise<LlmStatus> {
|
||||
const env = options.env;
|
||||
|
|
@ -208,8 +250,18 @@ async function buildLlmStatus(
|
|||
}
|
||||
if (backend === 'claude-code') {
|
||||
const modelName = model ?? 'sonnet';
|
||||
if (options.fast === true) {
|
||||
return {
|
||||
backend,
|
||||
model: modelName,
|
||||
status: 'skipped',
|
||||
detail: 'auth probe skipped (--fast)',
|
||||
};
|
||||
}
|
||||
const probe = options.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe;
|
||||
const auth = await probe({ projectDir: options.projectDir, model: modelName, env });
|
||||
const auth = await withSpinner(options.useSpinner === true, 'Probing Claude Code authentication', () =>
|
||||
probe({ projectDir: options.projectDir, model: modelName, env }),
|
||||
);
|
||||
if (auth.ok) {
|
||||
return {
|
||||
backend,
|
||||
|
|
@ -461,8 +513,22 @@ async function buildQueryHistoryStatus(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (options.fast === true) {
|
||||
statuses.push({
|
||||
connection: connectionId,
|
||||
dialect: 'postgres',
|
||||
status: 'skipped',
|
||||
detail: 'pg_stat_statements probe skipped (--fast)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await probe({ projectDir: project.projectDir, connectionId, connection, env });
|
||||
const result = await withSpinner(
|
||||
options.useSpinner === true,
|
||||
`Probing pg_stat_statements on ${connectionId}`,
|
||||
() => probe({ projectDir: project.projectDir, connectionId, connection, env }),
|
||||
);
|
||||
statuses.push({
|
||||
connection: connectionId,
|
||||
dialect: 'postgres',
|
||||
|
|
@ -641,7 +707,7 @@ function buildVerdict(
|
|||
reasons.push('embedding credentials missing');
|
||||
}
|
||||
}
|
||||
const missing = connections.filter((c) => c.status !== 'ok').length;
|
||||
const missing = connections.filter((c) => c.status !== 'ok' && c.status !== 'skipped').length;
|
||||
if (missing > 0) reasons.push(`${missing} connection${missing === 1 ? '' : 's'} need configuration`);
|
||||
const queryHistoryWarnings = queryHistory.filter((entry) => entry.status === 'warn').length;
|
||||
if (queryHistoryWarnings > 0) {
|
||||
|
|
@ -669,6 +735,27 @@ export interface BuildProjectStatusOptions {
|
|||
postgresQueryHistoryProbe?: PostgresQueryHistoryProbe;
|
||||
claudeCodeAuthProbe?: ClaudeCodeAuthProbe;
|
||||
configIssues?: KtxConfigIssue[];
|
||||
fast?: boolean;
|
||||
useSpinner?: boolean;
|
||||
}
|
||||
|
||||
async function withSpinner<T>(
|
||||
useSpinner: boolean,
|
||||
label: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!useSpinner) return run();
|
||||
const { spinner } = await import('@clack/prompts');
|
||||
const s = spinner();
|
||||
s.start(label);
|
||||
try {
|
||||
const result = await run();
|
||||
s.stop(label);
|
||||
return result;
|
||||
} catch (error) {
|
||||
s.stop(`${label} — failed`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
|
||||
|
|
@ -683,6 +770,219 @@ function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
|
|||
};
|
||||
}
|
||||
|
||||
interface DirSummary {
|
||||
fileCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
async function summarizeDir(
|
||||
dir: string,
|
||||
filter?: (entry: Dirent, fullPath: string) => boolean,
|
||||
maxDepth = 10,
|
||||
): Promise<DirSummary> {
|
||||
let fileCount = 0;
|
||||
let bytes = 0;
|
||||
const walk = async (current: string, depth: number): Promise<void> => {
|
||||
if (depth > maxDepth) return;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdirAsync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (filter && !filter(entry, full)) continue;
|
||||
try {
|
||||
const s = await statAsync(full);
|
||||
fileCount += 1;
|
||||
bytes += s.size;
|
||||
} catch {
|
||||
// skip individual stat failures
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(dir, 0);
|
||||
return { fileCount, bytes };
|
||||
}
|
||||
|
||||
function isMarkdownEntry(entry: Dirent): boolean {
|
||||
return entry.isFile() && /\.mdx?$/i.test(entry.name);
|
||||
}
|
||||
|
||||
function isYamlEntry(entry: Dirent): boolean {
|
||||
return entry.isFile() && /\.ya?ml$/i.test(entry.name);
|
||||
}
|
||||
|
||||
async function fileSizeOrNull(filePath: string): Promise<number | null> {
|
||||
try {
|
||||
const s = await statAsync(filePath);
|
||||
return s.isFile() ? s.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tryQuery<T>(run: () => T, fallback: T): T {
|
||||
try {
|
||||
return run();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildLocalStatsStatus(project: KtxLocalProject): Promise<LocalStatsStatus> {
|
||||
const dbPath = ktxLocalStateDbPath(project);
|
||||
const dbSqliteBytes = await fileSizeOrNull(dbPath);
|
||||
|
||||
const projectDirSummary: LocalStatsProjectDir = {
|
||||
dbSqliteBytes,
|
||||
ktxCacheBytes: (await summarizeDir(join(project.projectDir, '.ktx', 'cache'))).bytes,
|
||||
rawSources: await summarizeDir(join(project.projectDir, 'raw-sources')),
|
||||
wikiGlobalMarkdownCount: (
|
||||
await summarizeDir(join(project.projectDir, 'wiki', 'global'), isMarkdownEntry)
|
||||
).fileCount,
|
||||
semanticLayerYamlCount: (
|
||||
await summarizeDir(join(project.projectDir, 'semantic-layer'), isYamlEntry)
|
||||
).fileCount,
|
||||
};
|
||||
|
||||
if (dbSqliteBytes === null) {
|
||||
return {
|
||||
ingest: { totalCompletedRuns: 0, perConnection: [] },
|
||||
knowledgePages: [],
|
||||
semanticLayer: [],
|
||||
projectDir: projectDirSummary,
|
||||
unavailable: 'no .ktx/db.sqlite yet',
|
||||
};
|
||||
}
|
||||
|
||||
let database: import('better-sqlite3').Database | null = null;
|
||||
try {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
database = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const db = database;
|
||||
|
||||
const totalCompletedRuns = tryQuery(
|
||||
() =>
|
||||
(
|
||||
db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM local_ingest_reports WHERE status = 'done'`)
|
||||
.get() as { n: number } | undefined
|
||||
)?.n ?? 0,
|
||||
0,
|
||||
);
|
||||
|
||||
const ingestRows = tryQuery(
|
||||
() =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT connection_id, adapter, MAX(completed_at) AS last_completed_at
|
||||
FROM local_ingest_reports
|
||||
WHERE status = 'done'
|
||||
GROUP BY connection_id, adapter`,
|
||||
)
|
||||
.all() as Array<{ connection_id: string; adapter: string; last_completed_at: string }>,
|
||||
[] as Array<{ connection_id: string; adapter: string; last_completed_at: string }>,
|
||||
);
|
||||
const perConnectionMap = new Map<string, LocalStatsIngestPerConnection>();
|
||||
for (const row of ingestRows) {
|
||||
const existing = perConnectionMap.get(row.connection_id);
|
||||
if (!existing || row.last_completed_at > existing.lastCompletedAt) {
|
||||
perConnectionMap.set(row.connection_id, {
|
||||
connectionId: row.connection_id,
|
||||
adapter: row.adapter,
|
||||
lastCompletedAt: row.last_completed_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
const perConnection = [...perConnectionMap.values()].sort((left, right) =>
|
||||
left.connectionId.localeCompare(right.connectionId),
|
||||
);
|
||||
|
||||
const knowledgeRows = tryQuery(
|
||||
() =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT scope, COUNT(*) AS n FROM knowledge_pages GROUP BY scope ORDER BY scope`,
|
||||
)
|
||||
.all() as Array<{ scope: string; n: number }>,
|
||||
[] as Array<{ scope: string; n: number }>,
|
||||
);
|
||||
const knowledgePages: LocalStatsKnowledgeEntry[] = knowledgeRows.map((row) => ({
|
||||
scope: row.scope,
|
||||
count: row.n,
|
||||
}));
|
||||
|
||||
const sourceRows = tryQuery(
|
||||
() =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT connection_id, COUNT(*) AS n FROM local_sl_sources GROUP BY connection_id`,
|
||||
)
|
||||
.all() as Array<{ connection_id: string; n: number }>,
|
||||
[] as Array<{ connection_id: string; n: number }>,
|
||||
);
|
||||
const dictionaryRows = tryQuery(
|
||||
() =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT connection_id, COUNT(*) AS n FROM local_sl_dictionary_values GROUP BY connection_id`,
|
||||
)
|
||||
.all() as Array<{ connection_id: string; n: number }>,
|
||||
[] as Array<{ connection_id: string; n: number }>,
|
||||
);
|
||||
const slMap = new Map<string, LocalStatsSemanticLayerEntry>();
|
||||
for (const row of sourceRows) {
|
||||
slMap.set(row.connection_id, {
|
||||
connectionId: row.connection_id,
|
||||
sourceCount: row.n,
|
||||
dictionaryValueCount: 0,
|
||||
});
|
||||
}
|
||||
for (const row of dictionaryRows) {
|
||||
const existing = slMap.get(row.connection_id) ?? {
|
||||
connectionId: row.connection_id,
|
||||
sourceCount: 0,
|
||||
dictionaryValueCount: 0,
|
||||
};
|
||||
existing.dictionaryValueCount = row.n;
|
||||
slMap.set(row.connection_id, existing);
|
||||
}
|
||||
const semanticLayer = [...slMap.values()].sort((left, right) =>
|
||||
left.connectionId.localeCompare(right.connectionId),
|
||||
);
|
||||
|
||||
return {
|
||||
ingest: { totalCompletedRuns, perConnection },
|
||||
knowledgePages,
|
||||
semanticLayer,
|
||||
projectDir: projectDirSummary,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ingest: { totalCompletedRuns: 0, perConnection: [] },
|
||||
knowledgePages: [],
|
||||
semanticLayer: [],
|
||||
projectDir: projectDirSummary,
|
||||
unavailable: failureDetail(error),
|
||||
};
|
||||
} finally {
|
||||
if (database) {
|
||||
try {
|
||||
database.close();
|
||||
} catch {
|
||||
// ignore close failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): Promise<ProjectStatus> {
|
||||
const env = options.env ?? process.env;
|
||||
const config = project.config;
|
||||
|
|
@ -692,6 +992,8 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
|
|||
projectDir: project.projectDir,
|
||||
env,
|
||||
claudeCodeAuthProbe: options.claudeCodeAuthProbe,
|
||||
fast: options.fast,
|
||||
useSpinner: options.useSpinner,
|
||||
});
|
||||
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
|
||||
const storage = buildStorageStatus(config);
|
||||
|
|
@ -701,6 +1003,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
|
|||
const queryHistory = await buildQueryHistoryStatus(project, options);
|
||||
const pipeline = buildPipelineStatus(config);
|
||||
const warnings = buildWarnings(config, connections, llm, embeddings);
|
||||
const localStats = await buildLocalStatsStatus(project);
|
||||
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, queryHistory, warnings);
|
||||
|
||||
return {
|
||||
|
|
@ -714,6 +1017,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
|
|||
queryHistory,
|
||||
pipeline,
|
||||
warnings,
|
||||
localStats,
|
||||
verdict,
|
||||
verdictReason: reason,
|
||||
nextActions,
|
||||
|
|
@ -742,11 +1046,51 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
|
|||
|
||||
// ─── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗' };
|
||||
const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗', skipped: '-' };
|
||||
|
||||
function colorForLevel(useColor: boolean, level: ProjectStatusLevel, text: string): string {
|
||||
if (!useColor) return text;
|
||||
return level === 'ok' ? green(text) : level === 'warn' ? yellow(text) : red(text);
|
||||
if (level === 'ok') return green(text);
|
||||
if (level === 'warn') return yellow(text);
|
||||
if (level === 'fail') return red(text);
|
||||
return _dim(text);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; name: Intl.RelativeTimeFormatUnit }> = [
|
||||
{ amount: 60, name: 'second' },
|
||||
{ amount: 60, name: 'minute' },
|
||||
{ amount: 24, name: 'hour' },
|
||||
{ amount: 7, name: 'day' },
|
||||
{ amount: 4.34524, name: 'week' },
|
||||
{ amount: 12, name: 'month' },
|
||||
{ amount: Number.POSITIVE_INFINITY, name: 'year' },
|
||||
];
|
||||
|
||||
function formatRelativeFromNow(iso: string): string {
|
||||
const parsed = Date.parse(iso);
|
||||
if (Number.isNaN(parsed)) return iso;
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||
let duration = (parsed - Date.now()) / 1000;
|
||||
for (const division of RELATIVE_TIME_DIVISIONS) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return formatter.format(Math.round(duration), division.name);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
return iso;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -758,6 +1102,79 @@ function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string {
|
|||
return filePath;
|
||||
}
|
||||
|
||||
function renderLocalStats(
|
||||
lines: string[],
|
||||
stats: LocalStatsStatus,
|
||||
dim: (text: string) => string,
|
||||
bold: (text: string) => string,
|
||||
): void {
|
||||
lines.push(` ${bold('Local data')}`);
|
||||
if (stats.unavailable) {
|
||||
lines.push(` ${dim(`(—) ${stats.unavailable}`)}`);
|
||||
lines.push('');
|
||||
return;
|
||||
}
|
||||
|
||||
const localLabelWidth = Math.max(
|
||||
'Ingest'.length,
|
||||
'Knowledge'.length,
|
||||
'Semantic layer'.length,
|
||||
'Disk'.length,
|
||||
);
|
||||
const lLabel = (text: string) => text.padEnd(localLabelWidth);
|
||||
|
||||
const ingest = stats.ingest;
|
||||
const ingestSummary =
|
||||
ingest.totalCompletedRuns === 0
|
||||
? dim('no completed runs yet')
|
||||
: `${ingest.totalCompletedRuns} completed run${ingest.totalCompletedRuns === 1 ? '' : 's'}`;
|
||||
lines.push(` ${lLabel('Ingest')} ${ingestSummary}`);
|
||||
if (ingest.perConnection.length > 0) {
|
||||
const nameWidth = Math.max(...ingest.perConnection.map((entry) => entry.connectionId.length));
|
||||
const adapterWidth = Math.max(...ingest.perConnection.map((entry) => entry.adapter.length));
|
||||
for (const entry of ingest.perConnection) {
|
||||
lines.push(
|
||||
` ${entry.connectionId.padEnd(nameWidth)} ${dim(entry.adapter.padEnd(adapterWidth))} ${dim(`last ${formatRelativeFromNow(entry.lastCompletedAt)}`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (stats.knowledgePages.length === 0) {
|
||||
lines.push(` ${lLabel('Knowledge')} ${dim('no pages yet')}`);
|
||||
} else {
|
||||
const knowledgeText = stats.knowledgePages
|
||||
.map((entry) => `${entry.scope}=${entry.count}`)
|
||||
.join(` ${dim('·')} `);
|
||||
lines.push(` ${lLabel('Knowledge')} ${knowledgeText}`);
|
||||
}
|
||||
|
||||
if (stats.semanticLayer.length === 0) {
|
||||
lines.push(` ${lLabel('Semantic layer')} ${dim('no indexed sources yet')}`);
|
||||
} else {
|
||||
const nameWidth = Math.max(...stats.semanticLayer.map((entry) => entry.connectionId.length));
|
||||
let firstLine = true;
|
||||
for (const entry of stats.semanticLayer) {
|
||||
const prefix = firstLine ? lLabel('Semantic layer') : ' '.repeat(localLabelWidth);
|
||||
lines.push(
|
||||
` ${prefix} ${entry.connectionId.padEnd(nameWidth)} ${dim(`${entry.sourceCount} source${entry.sourceCount === 1 ? '' : 's'} · ${entry.dictionaryValueCount} dictionary value${entry.dictionaryValueCount === 1 ? '' : 's'}`)}`,
|
||||
);
|
||||
firstLine = false;
|
||||
}
|
||||
}
|
||||
|
||||
const disk = stats.projectDir;
|
||||
const diskBits: string[] = [];
|
||||
diskBits.push(`db=${disk.dbSqliteBytes === null ? '–' : formatBytes(disk.dbSqliteBytes)}`);
|
||||
diskBits.push(`cache=${formatBytes(disk.ktxCacheBytes)}`);
|
||||
diskBits.push(
|
||||
`raw-sources=${disk.rawSources.fileCount} file${disk.rawSources.fileCount === 1 ? '' : 's'} (${formatBytes(disk.rawSources.bytes)})`,
|
||||
);
|
||||
diskBits.push(`wiki=${disk.wikiGlobalMarkdownCount} md`);
|
||||
diskBits.push(`semantic-layer=${disk.semanticLayerYamlCount} yaml`);
|
||||
lines.push(` ${lLabel('Disk')} ${dim(diskBits.join(` ${dim('·')} `))}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
export interface RenderProjectStatusOptions {
|
||||
verbose?: boolean;
|
||||
useColor?: boolean;
|
||||
|
|
@ -859,6 +1276,9 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
|
|||
lines.push(` ${pLabel('Research agent')} ${agentDetail}`);
|
||||
lines.push('');
|
||||
|
||||
// Local data
|
||||
renderLocalStats(lines, status.localStats, dim, bold);
|
||||
|
||||
// Warnings
|
||||
if (status.warnings.length > 0) {
|
||||
lines.push(` ${bold('Warnings')}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue