@@ -59,6 +60,8 @@ serves that context to agents at runtime.
+
+
## Use it for
Use **ktx** when agents need more than raw database access. Agents can search wiki
diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs
index 380dba85..e47a0cc7 100644
--- a/docs-site/next.config.mjs
+++ b/docs-site/next.config.mjs
@@ -30,7 +30,36 @@ const config = {
};
},
async redirects() {
+ // Alias-host canonicalization MUST come before the generic root/docs
+ // redirects below. Those generic rules have no host guard, so if they ran
+ // first they would inject a "/ktx" basePath into the path on the alias
+ // hosts, which the alias catch-alls would then prepend a second time —
+ // producing https://docs.kaelio.com/ktx/ktx/docs/... Redirects also run
+ // before beforeFiles rewrites, so the ktx.sh catch-all must exclude
+ // /stars* to let the stars dashboard rewrite proxy through.
return [
+ {
+ source: "/slack",
+ has: [{ type: "host", value: "ktx.sh" }],
+ destination:
+ "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ",
+ permanent: false,
+ basePath: false,
+ },
+ {
+ source: "/:path*",
+ has: [{ type: "host", value: "docs.ktx.sh" }],
+ destination: "https://docs.kaelio.com/ktx/:path*",
+ permanent: true,
+ basePath: false,
+ },
+ {
+ source: "/:path((?!stars(?:/|$)).*)",
+ has: [{ type: "host", value: "ktx.sh" }],
+ destination: "https://docs.kaelio.com/ktx/:path",
+ permanent: true,
+ basePath: false,
+ },
{
source: "/",
destination: "/ktx/docs/getting-started/introduction",
@@ -43,28 +72,6 @@ const config = {
permanent: false,
basePath: false,
},
- {
- source: "/:path*",
- has: [{ type: "host", value: "docs.ktx.sh" }],
- destination: "https://docs.kaelio.com/ktx/:path*",
- permanent: true,
- basePath: false,
- },
- {
- source: "/slack",
- has: [{ type: "host", value: "ktx.sh" }],
- destination:
- "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ",
- permanent: false,
- basePath: false,
- },
- {
- source: "/:path((?!stars(?:/|$)).*)",
- has: [{ type: "host", value: "ktx.sh" }],
- destination: "https://docs.kaelio.com/ktx/:path",
- permanent: true,
- basePath: false,
- },
];
},
};
diff --git a/docs-site/tests/docs-index-route.test.mjs b/docs-site/tests/docs-index-route.test.mjs
index fdd8ec81..6fac0e3c 100644
--- a/docs-site/tests/docs-index-route.test.mjs
+++ b/docs-site/tests/docs-index-route.test.mjs
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { readFile, writeFile } from "node:fs/promises";
+import http from "node:http";
+import https from "node:https";
import { dirname, join } from "node:path";
import { createServer } from "node:net";
import { after, before, test } from "node:test";
@@ -100,6 +102,37 @@ after(async () => {
}
});
+// Node's fetch (undici) overwrites the Host header with the connection host,
+// so the alias-host redirect rules never match. The low-level http(s) client
+// sends Host verbatim, which is what the alias canonicalization keys off of.
+function requestWithHost(hostHeader, path) {
+ const target = new URL(docsSiteUrl);
+ const client = target.protocol === "https:" ? https : http;
+ const port =
+ target.port || (target.protocol === "https:" ? "443" : "80");
+
+ return new Promise((resolve, reject) => {
+ const request = client.request(
+ {
+ hostname: target.hostname,
+ port,
+ path,
+ method: "GET",
+ headers: { Host: hostHeader },
+ },
+ (response) => {
+ response.resume();
+ resolve({
+ status: response.statusCode,
+ location: response.headers.location,
+ });
+ },
+ );
+ request.on("error", reject);
+ request.end();
+ });
+}
+
test("/ktx/docs redirects to the docs introduction", async () => {
const response = await fetch(`${docsSiteUrl}${docsBasePath}/docs`, {
redirect: "manual",
@@ -141,3 +174,51 @@ test("/ktx/api/search returns docs search results", async () => {
"search should return at least one docs result",
);
});
+
+test("ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => {
+ const root = await requestWithHost("ktx.sh", "/");
+ assert.equal(root.status, 308);
+ assert.equal(root.location, "https://docs.kaelio.com/ktx/");
+ assert.ok(
+ !root.location.includes("/ktx/ktx"),
+ "the basePath must not be doubled",
+ );
+
+ const page = await requestWithHost(
+ "ktx.sh",
+ "/docs/getting-started/quickstart",
+ );
+ assert.equal(page.status, 308);
+ assert.equal(
+ page.location,
+ "https://docs.kaelio.com/ktx/docs/getting-started/quickstart",
+ );
+});
+
+test("docs.ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => {
+ const root = await requestWithHost("docs.ktx.sh", "/");
+ assert.equal(root.status, 308);
+ assert.equal(root.location, "https://docs.kaelio.com/ktx");
+ assert.ok(
+ !root.location.includes("/ktx/ktx"),
+ "the basePath must not be doubled",
+ );
+
+ const page = await requestWithHost("docs.ktx.sh", "/llms.txt");
+ assert.equal(page.status, 308);
+ assert.equal(page.location, "https://docs.kaelio.com/ktx/llms.txt");
+});
+
+test("ktx.sh keeps the /slack and /stars exceptions", async () => {
+ const slack = await requestWithHost("ktx.sh", "/slack");
+ assert.equal(slack.status, 307);
+ assert.match(slack.location, /^https:\/\/join\.slack\.com\//);
+
+ // /stars is proxied by a beforeFiles rewrite, so the apex catch-all must not
+ // canonicalize it to the docs host.
+ const stars = await requestWithHost("ktx.sh", "/stars");
+ assert.ok(
+ !(stars.location ?? "").startsWith("https://docs.kaelio.com"),
+ "the stars dashboard must not be redirected to the docs host",
+ );
+});
diff --git a/docs-site/tests/product-mechanics-content.test.mjs b/docs-site/tests/product-mechanics-content.test.mjs
index 5cce9001..d0c9471c 100644
--- a/docs-site/tests/product-mechanics-content.test.mjs
+++ b/docs-site/tests/product-mechanics-content.test.mjs
@@ -85,7 +85,7 @@ test("product mechanics component explains ingestion outputs", async () => {
"compile into SQL",
'"use client"',
"@xyflow/react",
- "
{
);
}
- assert.match(
- component,
+ // The ReactFlow canvas config lives in the shared FlowCanvas wrapper, which
+ // product-mechanics renders. Assert the static read-only behavior there.
+ const flowCanvas = await readDocsFile("components/flow-canvas.tsx");
+ for (const guard of [
/nodesDraggable=\{false\}/,
- "ReactFlow canvas should disable node dragging",
- );
- assert.match(
- component,
- /panOnDrag=\{false\}/,
- "ReactFlow canvas should disable panning",
- );
- assert.match(
- component,
+ /nodesConnectable=\{false\}/,
/zoomOnScroll=\{false\}/,
- "ReactFlow canvas should disable scroll zoom",
- );
+ /elementsSelectable=\{false\}/,
+ ]) {
+ assert.match(
+ flowCanvas,
+ guard,
+ `shared FlowCanvas should enforce static read-only behavior: ${guard}`,
+ );
+ }
assert.doesNotMatch(component, /raw-sources/);
assert.doesNotMatch(component, /\.ktx/);
diff --git a/docs-site/tests/product-runtime-content.test.mjs b/docs-site/tests/product-runtime-content.test.mjs
new file mode 100644
index 00000000..ac643faa
--- /dev/null
+++ b/docs-site/tests/product-runtime-content.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const docsSiteDir = join(dirname(fileURLToPath(import.meta.url)), "..");
+
+async function readDocsFile(path) {
+ return readFile(join(docsSiteDir, path), "utf8");
+}
+
+test("docs introduction renders the serving phase after ingestion", async () => {
+ const introduction = await readDocsFile(
+ "content/docs/getting-started/introduction.mdx",
+ );
+
+ assert.match(
+ introduction,
+ /import\s+\{\s*ProductRuntime\s*\}\s+from\s+"@\/components\/product-runtime";/,
+ );
+ assert.match(introduction, //);
+
+ const mechanicsIndex = introduction.indexOf("");
+ const runtimeIndex = introduction.indexOf("");
+ const useCaseIndex = introduction.indexOf("## Use it for");
+
+ assert.ok(
+ runtimeIndex > mechanicsIndex,
+ "serving diagram should appear after the ingestion diagram",
+ );
+ assert.ok(
+ runtimeIndex < useCaseIndex,
+ "serving diagram should appear before use-case sections",
+ );
+});
+
+test("product runtime component explains the serving cycle", async () => {
+ const component = await readDocsFile("components/product-runtime.tsx");
+
+ for (const expectedText of [
+ "How serving works",
+ "Serving flow",
+ "From an agent request to a governed answer",
+ "Your agent",
+ "Claude Code",
+ "Cursor",
+ "Codex",
+ "Search wiki + semantic layer",
+ "Return approved metrics",
+ "Compile metrics → SQL",
+ "Context layer",
+ "Database",
+ "search + read",
+ "read-only",
+ "wiki/*.md",
+ "semantic-layer/*.yaml",
+ '"use client"',
+ "@xyflow/react",
+ "FlowCanvas",
+ "getSmoothStepPath",
+ "animateMotion",
+ "runtime-particle",
+ "buildCyclePath",
+ ]) {
+ assert.ok(
+ component.includes(expectedText),
+ `component should include: ${expectedText}`,
+ );
+ }
+
+ assert.doesNotMatch(component, /raw-sources/);
+ assert.doesNotMatch(component, /
=22.0.0"
@@ -47,6 +51,7 @@
"@ai-sdk/devtools": "0.0.18",
"@ai-sdk/google-vertex": "^4.0.134",
"@anthropic-ai/claude-agent-sdk": "0.3.146",
+ "@clack/core": "1.3.1",
"@clack/prompts": "1.4.0",
"@clickhouse/client": "^1.18.5",
"@commander-js/extra-typings": "14.0.0",
@@ -72,6 +77,7 @@
"pg": "^8.21.0",
"posthog-node": "^5.34.9",
"react": "^19.2.6",
+ "semver": "^7.8.1",
"simple-git": "3.36.0",
"snowflake-sdk": "^2.4.2",
"yaml": "^2.9.0",
@@ -85,6 +91,7 @@
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
+ "@types/semver": "^7.7.1",
"@vitest/coverage-v8": "^4.1.7",
"ajv": "8.20.0",
"ink-testing-library": "^4.0.0",
diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts
index 2ad51e6c..31be2e1b 100644
--- a/packages/cli/src/clack.ts
+++ b/packages/cli/src/clack.ts
@@ -3,6 +3,30 @@ import type { KtxCliIo } from './cli-runtime.js';
const ESC = String.fromCharCode(0x1b);
+export interface CliStyleEnv {
+ NO_COLOR?: string;
+ TERM?: string;
+}
+
+function ansiEnabled(env: CliStyleEnv = process.env): boolean {
+ return !env.NO_COLOR && env.TERM !== 'dumb';
+}
+
+function ansiColor(text: string, open: number, close: number, env?: CliStyleEnv): string {
+ if (!ansiEnabled(env)) {
+ return text;
+ }
+ return `${ESC}[${open}m${text}${ESC}[${close}m`;
+}
+
+export function dim(text: string, env?: CliStyleEnv): string {
+ return ansiColor(text, 2, 22, env);
+}
+
+export function cyan(text: string, env?: CliStyleEnv): string {
+ return ansiColor(text, 36, 39, env);
+}
+
export interface RailBufferedSource {
stdoutText(): string;
stderrText(): string;
@@ -61,11 +85,11 @@ export function createClackSpinner(): KtxCliSpinner {
}
function magenta(text: string): string {
- return `${ESC}[35m${text}${ESC}[39m`;
+ return ansiColor(text, 35, 39);
}
function red(text: string): string {
- return `${ESC}[31m${text}${ESC}[39m`;
+ return ansiColor(text, 31, 39);
}
export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner {
diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts
index 31ab8a03..6359d897 100644
--- a/packages/cli/src/cli-program.ts
+++ b/packages/cli/src/cli-program.ts
@@ -16,6 +16,7 @@ import { renderMissingProjectMessage } from './doctor.js';
import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js';
import { profileMark, profileSpan } from './startup-profile.js';
import type { CommandOutcome } from './telemetry/index.js';
+import { prepareUpdateCheckNotice, type PrepareUpdateCheckNoticeOptions } from './update-check/update-check.js';
profileMark('module:cli-program');
@@ -39,6 +40,8 @@ interface KtxCommanderProgramOptions {
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise;
}
+type KtxCliUpdateCheckOptions = Pick;
+
export interface BuildKtxProgramOptions {
io: KtxCliIo;
deps: KtxCliDeps;
@@ -47,6 +50,7 @@ export interface BuildKtxProgramOptions {
setExitCode?: (code: number) => void;
argv?: string[];
setTelemetryModule?: (telemetry: typeof import('./telemetry/index.js')) => void;
+ updateCheck?: KtxCliUpdateCheckOptions;
}
type CommanderExitLike = { exitCode: number; code: string; message: string };
@@ -431,16 +435,29 @@ export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record<
export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
const program = createBaseProgram(options.packageInfo, options.io);
+ let pendingUpdateNotice: string | null = null;
+
program.hook('preAction', async (_thisCommand, actionCommand) => {
// The hidden completion command must stay silent and side-effect free: skip
- // the telemetry notice, command span, and project checks entirely.
+ // the telemetry notice, command span, project checks, and update checks entirely.
if (commandPath(actionCommand as CommandPathNode).includes('__complete')) {
return;
}
+ const commandNode = actionCommand as CommandPathNode;
+ const updateCheck = await prepareUpdateCheckNotice({
+ io: options.io,
+ env: options.updateCheck?.env,
+ fetchDistTags: options.updateCheck?.fetchDistTags,
+ homeDir: options.updateCheck?.homeDir,
+ installedVersion: options.packageInfo.version,
+ now: options.updateCheck?.now,
+ commandOptions: commandOptions(commandNode),
+ });
+ pendingUpdateNotice = updateCheck.notice;
+
const telemetry = await import('./telemetry/index.js');
options.setTelemetryModule?.(telemetry);
await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo);
- const commandNode = actionCommand as CommandPathNode;
const path = commandPath(commandNode);
const projectDir = resolveCommandProjectDir(commandNode);
const hasProject = ktxYamlExists(projectDir);
@@ -457,6 +474,13 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
ensureProjectAvailable(options.io, commandNode);
});
+ program.hook('postAction', () => {
+ if (pendingUpdateNotice) {
+ options.io.stderr.write(pendingUpdateNotice);
+ pendingUpdateNotice = null;
+ }
+ });
+
const context: KtxCliCommandContext = {
io: options.io,
deps: options.deps,
@@ -529,6 +553,13 @@ export async function runCommanderKtxCli(
try {
return await runBareInteractiveCommand(program, io, context);
} catch (error) {
+ const telemetry = await import('./telemetry/index.js');
+ await telemetry.reportException({
+ error,
+ context: { source: 'bare-interactive', handled: true, fatal: false },
+ packageInfo: info,
+ io,
+ });
io.stderr.write(`${formatCliError(error)}\n`);
return 1;
}
@@ -563,6 +594,23 @@ export async function runCommanderKtxCli(
outcome: commandOutcomeForParseResult(parseError, exitCode),
error: parseError,
});
+ if (
+ parseError &&
+ !isCommanderExit(parseError) &&
+ !isKtxProjectMissingAbortError(parseError)
+ ) {
+ await telemetryModule.reportException({
+ error: parseError,
+ context: {
+ source: completed?.commandPath.join(' ') ?? 'commander parseAsync',
+ handled: true,
+ fatal: false,
+ },
+ projectDir: completed?.projectGroupAttached ? completed.projectDir : undefined,
+ packageInfo: info,
+ io,
+ });
+ }
await telemetryModule.emitCompletedCommand({ completed, packageInfo: info, io });
await telemetryModule.shutdownTelemetryEmitter();
}
diff --git a/packages/cli/src/cli-runtime.ts b/packages/cli/src/cli-runtime.ts
index 7043143b..4e13b472 100644
--- a/packages/cli/src/cli-runtime.ts
+++ b/packages/cli/src/cli-runtime.ts
@@ -129,6 +129,48 @@ function installTelemetrySignalFlush(io: KtxCliIo, info: KtxCliPackageInfo): ()
};
}
+/** @internal */
+export function createGlobalExceptionReporter(io: KtxCliIo, info: KtxCliPackageInfo) {
+ return async (source: 'uncaughtException' | 'unhandledRejection', error: unknown): Promise => {
+ const { reportException, shutdownTelemetryEmitter } = await import('./telemetry/index.js');
+ await reportException({
+ error,
+ context: { source, handled: false, fatal: true },
+ io,
+ packageInfo: info,
+ immediate: true,
+ });
+ await shutdownTelemetryEmitter();
+ };
+}
+
+export function installGlobalExceptionHandlers(io: KtxCliIo, info: KtxCliPackageInfo): () => void {
+ const report = createGlobalExceptionReporter(io, info);
+ const handle = (source: 'uncaughtException' | 'unhandledRejection', error: unknown): void => {
+ void (async () => {
+ try {
+ await report(source, error);
+ } catch {
+ // Best-effort: preserve Node's process termination behavior.
+ }
+ if (error instanceof Error && error.stack) {
+ io.stderr.write(`${error.stack}\n`);
+ } else {
+ io.stderr.write(`${String(error)}\n`);
+ }
+ process.exit(1);
+ })();
+ };
+ const onUncaught = (error: Error): void => handle('uncaughtException', error);
+ const onUnhandled = (reason: unknown): void => handle('unhandledRejection', reason);
+ process.on('uncaughtException', onUncaught);
+ process.on('unhandledRejection', onUnhandled);
+ return () => {
+ process.off('uncaughtException', onUncaught);
+ process.off('unhandledRejection', onUnhandled);
+ };
+}
+
export async function runKtxCli(
argv = process.argv.slice(2),
io: KtxCliIo = process,
@@ -141,11 +183,14 @@ export async function runKtxCli(
// Real-process entry only: flush telemetry if interrupted. Test/programmatic
// callers pass their own `io`, so they never install process-level handlers.
const removeSignalFlush = (io as unknown) === process ? installTelemetrySignalFlush(io, info) : undefined;
+ const removeGlobalExceptionHandlers =
+ (io as unknown) === process ? installGlobalExceptionHandlers(io, info) : undefined;
try {
return await runCommanderKtxCli(argv, io, deps, info, {
runInit: runInitForCommander,
});
} finally {
+ removeGlobalExceptionHandlers?.();
removeSignalFlush?.();
}
}
diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts
index 1619a80a..0302e9ed 100644
--- a/packages/cli/src/commands/setup-commands.ts
+++ b/packages/cli/src/commands/setup-commands.ts
@@ -406,6 +406,8 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
}
const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project';
+ const debugEnabled =
+ ((command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as { debug?: unknown }).debug === true;
await runSetupArgs(context, {
command: 'run',
projectDir: resolveCommandProjectDir(command),
@@ -415,6 +417,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
agentScope: resolvedAgentScope,
skipAgents: options.skipAgents === true,
inputMode: options.input === false ? 'disabled' : 'auto',
+ ...(debugEnabled ? { debug: true } : {}),
yes: options.yes === true,
cliVersion: context.packageInfo.version,
...(options.llmBackend ? { llmBackend: options.llmBackend } : {}),
diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts
index 96281e82..9b6b4294 100644
--- a/packages/cli/src/connection.ts
+++ b/packages/cli/src/connection.ts
@@ -16,7 +16,8 @@ import { bold, dim, green, red, SYMBOLS } from './io/symbols.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:connection');
@@ -74,6 +75,12 @@ async function testNativeConnection(
}
const result = await connector.testConnection();
if (!result.success) {
+ // Re-throw the driver's original error so connection_test telemetry records
+ // its real class (e.g. ConnectionError) and code (e.g. ELOGIN) instead of
+ // collapsing every native failure to a generic Error with no code.
+ if (result.cause instanceof Error) {
+ throw result.cause;
+ }
throw new Error(result.error ?? 'connection test failed');
}
return { driver: connector.driver };
@@ -318,6 +325,21 @@ async function emitConnectionTest(input: {
...(errorDetail ? { errorDetail } : {}),
},
});
+ if (input.error) {
+ await reportException({
+ error: input.error,
+ context: { source: 'connection test', handled: true, fatal: false },
+ projectDir: input.project.projectDir,
+ io: input.io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project: input.project,
+ connectionId: input.connectionId,
+ includeLlm: false,
+ includeEmbeddings: false,
+ env: process.env,
+ }),
+ });
+ }
}
function visualWidth(text: string): number {
diff --git a/packages/cli/src/connectors/bigquery/connector.ts b/packages/cli/src/connectors/bigquery/connector.ts
index edebe284..eae0f2ed 100644
--- a/packages/cli/src/connectors/bigquery/connector.ts
+++ b/packages/cli/src/connectors/bigquery/connector.ts
@@ -5,7 +5,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -320,7 +322,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
this.id = `bigquery:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
const client = this.getClient();
await client.getDatasets({ maxResults: 1 });
@@ -329,7 +331,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
}
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/clickhouse/connector.ts b/packages/cli/src/connectors/clickhouse/connector.ts
index 74ef7a77..23622701 100644
--- a/packages/cli/src/connectors/clickhouse/connector.ts
+++ b/packages/cli/src/connectors/clickhouse/connector.ts
@@ -1,7 +1,7 @@
import { createClient } from '@clickhouse/client';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
-import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js';
+import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import { readFileSync } from 'node:fs';
import { Agent as HttpsAgent } from 'node:https';
@@ -317,12 +317,12 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
this.id = `clickhouse:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts
index 29dacc26..c147c7dd 100644
--- a/packages/cli/src/connectors/mysql/connector.ts
+++ b/packages/cli/src/connectors/mysql/connector.ts
@@ -11,7 +11,9 @@ import {
} from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -413,12 +415,12 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
this.id = `mysql:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts
index f206fa6a..1a956a3d 100644
--- a/packages/cli/src/connectors/postgres/connector.ts
+++ b/packages/cli/src/connectors/postgres/connector.ts
@@ -6,7 +6,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -442,12 +444,12 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
this.id = `postgres:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/snowflake/connector.ts b/packages/cli/src/connectors/snowflake/connector.ts
index 86d7ebe7..51a91e52 100644
--- a/packages/cli/src/connectors/snowflake/connector.ts
+++ b/packages/cli/src/connectors/snowflake/connector.ts
@@ -7,7 +7,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -464,7 +466,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
@@ -573,7 +575,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector {
}
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
return this.getDriver().test();
}
diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts
index e996bc25..f5ba2a55 100644
--- a/packages/cli/src/connectors/sqlite/connector.ts
+++ b/packages/cli/src/connectors/sqlite/connector.ts
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { normalizeQueryRows } from '../../context/connections/query-executor.js';
-import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
+import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
export interface KtxSqliteConnectionConfig {
@@ -167,7 +167,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
this.id = `sqlite:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) {
return { success: false, error: `File not found: ${this.dbPath}` };
@@ -175,7 +175,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
this.database().prepare('SELECT 1').get();
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts
index 0115781d..5dd9969b 100644
--- a/packages/cli/src/connectors/sqlserver/connector.ts
+++ b/packages/cli/src/connectors/sqlserver/connector.ts
@@ -3,7 +3,9 @@ import { getDialectForDriver } from '../../context/connections/dialects.js';
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
+ connectorTestFailure,
createKtxConnectorCapabilities,
+ type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
@@ -384,12 +386,12 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
this.id = `sqlserver:${options.connectionId}`;
}
- async testConnection(): Promise<{ success: boolean; error?: string }> {
+ async testConnection(): Promise {
try {
await this.query('SELECT 1');
return { success: true };
} catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
+ return connectorTestFailure(error);
}
}
diff --git a/packages/cli/src/context/core/abort.ts b/packages/cli/src/context/core/abort.ts
new file mode 100644
index 00000000..95467c52
--- /dev/null
+++ b/packages/cli/src/context/core/abort.ts
@@ -0,0 +1,39 @@
+/** @internal */
+export function createAbortError(message = 'Aborted'): DOMException {
+ return new DOMException(message, 'AbortError');
+}
+
+export function isAbortError(error: unknown): boolean {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return true;
+ }
+ if (!error || typeof error !== 'object') {
+ return false;
+ }
+ const record = error as { name?: unknown; code?: unknown };
+ return record.name === 'AbortError' || record.code === 'ABORT_ERR';
+}
+
+/** @internal */
+export function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) {
+ throw createAbortError();
+ }
+}
+
+export function linkAbortSignal(parent?: AbortSignal): { controller: AbortController; dispose: () => void } {
+ const controller = new AbortController();
+ if (!parent) {
+ return { controller, dispose: () => undefined };
+ }
+ if (parent.aborted) {
+ controller.abort(createAbortError());
+ return { controller, dispose: () => undefined };
+ }
+ const onAbort = () => controller.abort(createAbortError());
+ parent.addEventListener('abort', onAbort, { once: true });
+ return {
+ controller,
+ dispose: () => parent.removeEventListener('abort', onAbort),
+ };
+}
diff --git a/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts
index bb296513..3f77900d 100644
--- a/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts
+++ b/packages/cli/src/context/ingest/adapters/historic-sql/query-history-filter-picker.ts
@@ -23,6 +23,7 @@ export interface QueryHistoryFilterProposal {
consideredRoleCount: number;
skipped: { reason: 'no-llm' | 'no-daemon' | 'no-in-scope-history' | 'user-block-present' } | null;
warnings: string[];
+ parseFailedTemplateIds: string[];
}
export interface ProposeQueryHistoryServiceAccountFiltersInput {
@@ -74,7 +75,7 @@ const queryHistoryFilterAdjudicationSchema = z.object({
type QueryHistoryFilterAdjudication = z.infer;
function emptyProposal(skipped: QueryHistoryFilterProposal['skipped'], warnings: string[] = []): QueryHistoryFilterProposal {
- return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings };
+ return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings, parseFailedTemplateIds: [] };
}
function displayTableRef(ref: KtxTableRef): string {
@@ -180,6 +181,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
const windowDays = 'windowDays' in config ? config.windowDays : 90;
const windowStart = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const warnings: string[] = [];
+ const parseFailedTemplateIds: string[] = [];
const snapshot: AggregatedTemplate[] = [];
try {
@@ -212,7 +214,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
for (const template of snapshot) {
const parsed = analysis.get(template.templateId);
if (!parsed || parsed.error) {
- warnings.push(`query_history_filter_picker_parse_failed:${template.templateId}`);
+ parseFailedTemplateIds.push(template.templateId);
continue;
}
const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()]
@@ -236,6 +238,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
consideredRoleCount: records.length,
skipped: { reason: 'no-in-scope-history' },
warnings,
+ parseFailedTemplateIds,
};
}
@@ -256,6 +259,7 @@ export async function proposeQueryHistoryServiceAccountFilters(
...warnings,
`query_history_filter_picker_llm_failed:${error instanceof Error ? error.message : String(error)}`,
],
+ parseFailedTemplateIds,
};
}
@@ -274,5 +278,6 @@ export async function proposeQueryHistoryServiceAccountFilters(
consideredRoleCount: records.length,
skipped: input.userServiceAccountsPresent ? { reason: 'user-block-present' } : null,
warnings,
+ parseFailedTemplateIds,
};
}
diff --git a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts b/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts
index 348544ca..7848fab7 100644
--- a/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts
+++ b/packages/cli/src/context/ingest/context-candidates/curator-pagination.service.ts
@@ -40,6 +40,7 @@ export interface CuratorPaginationInput {
buildToolSet: (passNumber: number) => KtxRuntimeToolSet;
getReconciliationActions: () => MemoryAction[];
onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
}
interface CuratorPaginationResult extends ReconciliationOutcome {
@@ -243,6 +244,7 @@ export class CuratorPaginationService implements CuratorPaginationPort {
sourceKey: params.input.sourceKey,
jobId: params.input.jobId,
forceRun: params.forceRun,
+ abortSignal: params.input.abortSignal,
onStepFinish: params.input.onStepFinish
? ({ stepIndex, stepBudget }) =>
params.input.onStepFinish?.({ passNumber: params.passNumber, stepIndex, stepBudget })
diff --git a/packages/cli/src/context/ingest/final-gate-repair.ts b/packages/cli/src/context/ingest/final-gate-repair.ts
index 1c373aa6..f32178d8 100644
--- a/packages/cli/src/context/ingest/final-gate-repair.ts
+++ b/packages/cli/src/context/ingest/final-gate-repair.ts
@@ -21,6 +21,7 @@ export interface RepairFinalGateFailureInput {
repairKind: FinalGateRepairKind;
maxAttempts?: number;
stepBudget?: number;
+ abortSignal?: AbortSignal;
}
const readRepairFileSchema = z.object({
@@ -200,6 +201,7 @@ export async function repairFinalGateFailure(
jobId: input.trace.context.jobId,
repairKind: input.repairKind,
},
+ abortSignal: input.abortSignal,
}),
);
diff --git a/packages/cli/src/context/ingest/ingest-bundle.runner.ts b/packages/cli/src/context/ingest/ingest-bundle.runner.ts
index 3f2b41d3..a242d58a 100644
--- a/packages/cli/src/context/ingest/ingest-bundle.runner.ts
+++ b/packages/cli/src/context/ingest/ingest-bundle.runner.ts
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
import pLimit from 'p-limit';
import { z } from 'zod';
import { type KtxLogger, noopLogger } from '../../context/core/config.js';
+import type { RateLimitWaitState } from '../../context/llm/rate-limit-governor.js';
import { createRuntimeToolDescriptorFromAiTool } from '../../context/llm/runtime-tools.js';
import type { KtxRuntimeToolSet } from '../../context/llm/runtime-port.js';
import type { CaptureSession, MemoryAction } from '../../context/memory/types.js';
@@ -219,6 +220,10 @@ export class IngestBundleRunner {
}
async run(job: IngestBundleJob, ctx?: IngestJobContext): Promise {
+ const unsubscribeRateLimitGovernor = this.subscribeRateLimitGovernor({
+ trace: this.createTrace(job),
+ memoryFlow: ctx?.memoryFlow,
+ });
const key = job.connectionId;
const previous = this.chainByConnection.get(key);
if (previous) {
@@ -241,10 +246,72 @@ export class IngestBundleRunner {
ctx?.memoryFlow?.finish('error', [sanitizeMemoryFlowError(error)]);
throw error;
} finally {
+ unsubscribeRateLimitGovernor();
await this.maybeEmitIngestProfile(job.jobId);
}
}
+ private formatRateLimitWait(
+ state: Extract,
+ ): string {
+ const seconds = Math.ceil(state.remainingMs / 1_000);
+ const minutes = Math.floor(seconds / 60);
+ const remainder = seconds % 60;
+ const duration = minutes > 0 ? `${minutes}m${String(remainder).padStart(2, '0')}s` : `${seconds}s`;
+ const type = state.rateLimitType ? ` ${state.rateLimitType}` : '';
+ return `Rate-limited (${state.provider}${type}); resuming in ${duration}; Ctrl+C to stop`;
+ }
+
+ private subscribeRateLimitGovernor(input: {
+ trace: IngestTraceWriter;
+ memoryFlow?: MemoryFlowEventSink;
+ }): () => void {
+ const governor = this.deps.settings.rateLimitGovernor;
+ if (!governor) {
+ return () => undefined;
+ }
+ return governor.subscribe((state: RateLimitWaitState) => {
+ if (state.kind === 'rate_limit_observed') {
+ void input.trace.event('info', 'rate_limit', 'rate_limit_observed', { ...state });
+ return;
+ }
+ if (state.kind === 'concurrency_adjusted') {
+ void input.trace.event('info', 'rate_limit', 'concurrency_adjusted', { ...state });
+ return;
+ }
+ void input.trace.event('info', 'rate_limit', state.kind, { ...state });
+ if (state.kind === 'wait_tick' || state.kind === 'wait_started') {
+ input.memoryFlow?.emit({
+ type: 'rate_limit_wait',
+ provider: state.provider,
+ ...(state.rateLimitType ? { rateLimitType: state.rateLimitType } : {}),
+ resumeAtMs: state.resumeAtMs,
+ remainingMs: state.remainingMs,
+ });
+ input.memoryFlow?.emit({
+ type: 'stage_progress',
+ stage: 'integration',
+ percent: 50,
+ message: this.formatRateLimitWait(state),
+ transient: true,
+ });
+ }
+ });
+ }
+
+ private async withRateLimitWorkSlot(abortSignal: AbortSignal | undefined, fn: () => Promise): Promise {
+ const governor = this.deps.settings.rateLimitGovernor;
+ if (!governor) {
+ return fn();
+ }
+ const release = await governor.acquireWorkSlot(abortSignal);
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+ }
+
/**
* When profiling is enabled — via the `KTX_PROFILE_INGEST` env var or the
* `ingest.profile` config setting — read the job's trace + tool transcripts
@@ -877,6 +944,7 @@ export class IngestBundleRunner {
includeContextEvidenceTools: boolean;
currentTableExists(tableRef: string): Promise;
memoryFlow?: MemoryFlowEventSink;
+ abortSignal?: AbortSignal;
wuSkillNames: string[];
onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void;
}): Promise {
@@ -1029,6 +1097,7 @@ export class IngestBundleRunner {
jobId: input.job.jobId,
toolFailureCount: (unitKey) => input.transcriptSummaries.get(unitKey)?.fatalErrorCount ?? 0,
onStepFinish: input.onStepFinish,
+ abortSignal: input.abortSignal,
},
input.wu,
);
@@ -1524,7 +1593,8 @@ export class IngestBundleRunner {
try {
await Promise.all(
workUnits.map((wu, index) =>
- limitWorkUnit(async () => {
+ limitWorkUnit(() =>
+ this.withRateLimitWorkSlot(ctx?.abortSignal, async () => {
const outcome = await runIsolatedWorkUnit({
unitIndex: index,
ingestionBaseSha,
@@ -1532,6 +1602,7 @@ export class IngestBundleRunner {
patchDir,
trace: runTrace,
workUnit: wu,
+ abortSignal: ctx?.abortSignal,
afterSuccess: (child) => copyTransientIngestEvidence(child.workdir, sessionWorktree.workdir),
run: async (child) => {
const scopedWikiService = this.deps.wikiService.forWorktree(child.workdir);
@@ -1565,6 +1636,7 @@ export class IngestBundleRunner {
includeContextEvidenceTools: adapter.evidenceIndexing === 'documents' && !!contextReport,
currentTableExists: (tableRef) =>
this.tableRefExistsInSemanticLayer(scopedSemanticLayerService, slConnectionIds, tableRef),
+ abortSignal: ctx?.abortSignal,
memoryFlow,
wuSkillNames,
onStepFinish: ({ stepIndex, stepBudget }) => {
@@ -1594,7 +1666,8 @@ export class IngestBundleRunner {
completedWorkUnits / workUnits.length,
`${completedWorkUnits} of ${workUnits.length} work units complete`,
);
- }),
+ }),
+ ),
),
);
} catch (error) {
@@ -1693,6 +1766,7 @@ export class IngestBundleRunner {
reason: context.reason,
maxAttempts: 1,
stepBudget: 12,
+ abortSignal: ctx?.abortSignal,
});
emitStageProgress(
'integration',
@@ -1714,6 +1788,7 @@ export class IngestBundleRunner {
repairKind: 'patch_semantic_gate',
maxAttempts: 1,
stepBudget: 16,
+ abortSignal: ctx?.abortSignal,
});
emitStageProgress(
'integration',
@@ -1993,6 +2068,7 @@ export class IngestBundleRunner {
);
}
: undefined,
+ abortSignal: ctx?.abortSignal,
});
curatorReport = curatorOutcome.report;
curatorWarnings = curatorOutcome.warnings;
@@ -2038,6 +2114,7 @@ export class IngestBundleRunner {
sourceKey: job.sourceKey,
jobId: job.jobId,
force: !!overrideReport,
+ abortSignal: ctx?.abortSignal,
onStepFinish: stage4
? ({ stepIndex, stepBudget }) => {
emitStageProgress('reconciliation', 85, `Reconciling results: step ${stepIndex}/${stepBudget}`, {
@@ -2470,6 +2547,7 @@ export class IngestBundleRunner {
repairKind: 'final_artifact_gate',
maxAttempts: 1,
stepBudget: 16,
+ abortSignal: ctx?.abortSignal,
});
isolatedDiffSummary.gateRepairAttempts += gateRepair.attempts;
diff --git a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts b/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts
index 5ae551d1..c4a00448 100644
--- a/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts
+++ b/packages/cli/src/context/ingest/isolated-diff/textual-conflict-resolver.ts
@@ -19,6 +19,7 @@ export interface ResolveTextualConflictInput {
reason: string;
maxAttempts?: number;
stepBudget?: number;
+ abortSignal?: AbortSignal;
}
const readIntegrationFileSchema = z.object({
@@ -208,6 +209,7 @@ export async function resolveTextualConflict(
jobId: input.trace.context.jobId,
unitKey: input.unitKey,
},
+ abortSignal: input.abortSignal,
}),
);
diff --git a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts b/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts
index 7475612e..5ab52102 100644
--- a/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts
+++ b/packages/cli/src/context/ingest/isolated-diff/work-unit-executor.ts
@@ -14,6 +14,7 @@ export interface RunIsolatedWorkUnitInput {
patchDir: string;
trace: IngestTraceWriter;
workUnit: WorkUnit;
+ abortSignal?: AbortSignal;
run(child: IngestSessionWorktree): Promise;
afterSuccess?(child: IngestSessionWorktree): Promise;
}
diff --git a/packages/cli/src/context/ingest/local-bundle-runtime.ts b/packages/cli/src/context/ingest/local-bundle-runtime.ts
index 9d6aba95..e4c45b3f 100644
--- a/packages/cli/src/context/ingest/local-bundle-runtime.ts
+++ b/packages/cli/src/context/ingest/local-bundle-runtime.ts
@@ -12,6 +12,7 @@ import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-
import { createRuntimeToolDescriptorFromAiTool } from '../../context/llm/runtime-tools.js';
import { createLocalKtxLlmRuntimeFromConfig } from '../../context/llm/local-config.js';
import { KtxIngestEmbeddingPortAdapter } from '../../context/llm/embedding-port.js';
+import { createRateLimitGovernorConfig, RateLimitGovernor } from '../../context/llm/rate-limit-governor.js';
import { RuntimeAgentRunner, type AgentRunnerPort, type KtxLlmRuntimePort, type KtxRuntimeToolSet } from '../../context/llm/runtime-port.js';
import type { KtxEmbeddingProvider } from '../../llm/types.js';
import type { KtxLocalProject } from '../../context/project/project.js';
@@ -619,7 +620,7 @@ function localIngestLlmProviderGuardMessage(projectDir: string): string {
].join('\n');
}
-function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
+function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions, rateLimitGovernor: RateLimitGovernor): {
agentRunner: AgentRunnerPort;
llmRuntime?: KtxLlmRuntimePort;
} {
@@ -628,6 +629,7 @@ function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
(options.createLlmRuntime ?? createLocalKtxLlmRuntimeFromConfig)(options.project.config.llm, {
projectDir: options.project.projectDir,
env: process.env,
+ rateLimitGovernor,
}) ??
undefined;
@@ -677,7 +679,13 @@ export function createLocalBundleIngestRuntime(
const knowledgeIndex = new LocalKnowledgeIndex(options.project, embedding);
const knowledgeEvents = new NoopKnowledgeEventPort();
const wikiService = new KnowledgeWikiService(rootFileStore, embedding, knowledgeIndex, options.project.git, logger);
- const { agentRunner, llmRuntime } = resolveAgentRunner(options);
+ const rateLimitGovernor = new RateLimitGovernor(
+ createRateLimitGovernorConfig({
+ ...options.project.config.ingest.rateLimit,
+ maxConcurrency: options.project.config.ingest.workUnits.maxConcurrency,
+ }),
+ );
+ const { agentRunner, llmRuntime } = resolveAgentRunner(options, rateLimitGovernor);
const promptService = new PromptService({ promptsDir, partials: [], logger });
const storage = new LocalIngestStorage(options.project);
const registry = registerAdapters(options.adapters);
@@ -717,6 +725,7 @@ export function createLocalBundleIngestRuntime(
workUnitMaxConcurrency: options.project.config.ingest.workUnits.maxConcurrency,
workUnitStepBudget: options.project.config.ingest.workUnits.stepBudget,
workUnitFailureMode: options.project.config.ingest.workUnits.failureMode,
+ rateLimitGovernor,
profileIngest: options.project.config.ingest.profile,
ingestTraceLevel: ingestTraceLevelFromEnv(),
},
diff --git a/packages/cli/src/context/ingest/local-ingest.ts b/packages/cli/src/context/ingest/local-ingest.ts
index ec8a72f4..1a219629 100644
--- a/packages/cli/src/context/ingest/local-ingest.ts
+++ b/packages/cli/src/context/ingest/local-ingest.ts
@@ -3,6 +3,7 @@ import { cp, mkdir, rm } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import type { KtxSqlQueryExecutorPort } from '../../context/connections/query-executor.js';
import type { KtxLogger } from '../../context/core/config.js';
+import { createAbortError, isAbortError } from '../../context/core/abort.js';
import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-layer-compute.js';
import type { AgentRunnerPort, KtxLlmRuntimePort } from '../../context/llm/runtime-port.js';
import type { KtxLocalProject } from '../../context/project/project.js';
@@ -36,6 +37,7 @@ export interface RunLocalIngestOptions {
queryExecutor?: KtxSqlQueryExecutorPort;
logger?: KtxLogger;
embeddingProvider?: import('../../llm/types.js').KtxEmbeddingProvider | null;
+ abortSignal?: AbortSignal;
}
export interface LocalIngestResult {
@@ -123,10 +125,11 @@ function findAdapter(adapters: SourceAdapter[], source: string): SourceAdapter {
return adapter;
}
-function localJobContext(jobId: string, memoryFlow?: MemoryFlowEventSink): IngestJobContext {
+function localJobContext(jobId: string, memoryFlow?: MemoryFlowEventSink, abortSignal?: AbortSignal): IngestJobContext {
return {
jobId,
...(memoryFlow ? { memoryFlow } : {}),
+ ...(abortSignal ? { abortSignal } : {}),
startPhase() {
return new LocalIngestPhase();
},
@@ -158,6 +161,7 @@ async function runScheduledPullJob(options: {
queryExecutor?: KtxSqlQueryExecutorPort;
logger?: KtxLogger;
embeddingProvider?: import('../../llm/types.js').KtxEmbeddingProvider | null;
+ abortSignal?: AbortSignal;
}): Promise {
const runtime = createLocalBundleIngestRuntime(options);
const jobId = options.jobId ?? runtime.nextJobId();
@@ -169,7 +173,7 @@ async function runScheduledPullJob(options: {
trigger: options.trigger ?? 'manual_resync',
bundleRef: { kind: 'scheduled_pull', config: options.pullConfig },
},
- localJobContext(jobId, options.memoryFlow),
+ localJobContext(jobId, options.memoryFlow, options.abortSignal),
);
const report = await runtime.store.findByJobId(jobId);
if (!report) {
@@ -212,6 +216,7 @@ export async function runLocalIngest(options: RunLocalIngestOptions): Promise KtxRuntimeToolSet;
getReconciliationActions: () => MemoryAction[];
onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
}): Promise;
}
diff --git a/packages/cli/src/context/ingest/stages/stage-3-work-units.ts b/packages/cli/src/context/ingest/stages/stage-3-work-units.ts
index ec514a02..a7387c8a 100644
--- a/packages/cli/src/context/ingest/stages/stage-3-work-units.ts
+++ b/packages/cli/src/context/ingest/stages/stage-3-work-units.ts
@@ -1,4 +1,5 @@
import type { KtxModelRole } from '../../../llm/types.js';
+import { isAbortError } from '../../core/abort.js';
import type { AgentRunnerPort, KtxRuntimeToolSet, RunLoopMetrics } from '../../../context/llm/runtime-port.js';
import type { CaptureSession, MemoryAction } from '../../../context/memory/types.js';
import { listTouchedSlSources, type TouchedSlSource } from '../../../context/tools/touched-sl-sources.js';
@@ -28,6 +29,7 @@ export interface WorkUnitExecutionDeps {
connectionId: string;
jobId: string;
onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
toolFailureCount?: (unitKey: string) => number;
}
@@ -106,8 +108,12 @@ export async function executeWorkUnit(deps: WorkUnitExecutionDeps, wu: WorkUnit)
jobId: deps.jobId,
},
onStepFinish: deps.onStepFinish,
+ abortSignal: deps.abortSignal,
});
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
return failWithResetFromCurrentHead(error instanceof Error ? error.message : String(error));
}
diff --git a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts b/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts
index 5abc9bfb..c78e1b48 100644
--- a/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts
+++ b/packages/cli/src/context/ingest/stages/stage-4-reconciliation.ts
@@ -16,6 +16,7 @@ export interface ReconciliationContext {
jobId: string;
force?: boolean;
onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void;
+ abortSignal?: AbortSignal;
forceRun?: boolean;
}
@@ -40,6 +41,7 @@ export async function runReconciliationStage4(ctx: ReconciliationContext): Promi
stepBudget: ctx.stepBudget,
telemetryTags: { operationName: 'ingest-bundle-reconcile', source: ctx.sourceKey, jobId: ctx.jobId },
onStepFinish: ctx.onStepFinish,
+ abortSignal: ctx.abortSignal,
});
return { skipped: false, stopReason: run.stopReason, error: run.error, ...(run.metrics ? { metrics: run.metrics } : {}) };
}
diff --git a/packages/cli/src/context/ingest/types.ts b/packages/cli/src/context/ingest/types.ts
index 337885af..925f3d82 100644
--- a/packages/cli/src/context/ingest/types.ts
+++ b/packages/cli/src/context/ingest/types.ts
@@ -220,5 +220,6 @@ export interface IngestJobPhase {
export interface IngestJobContext {
jobId: string;
memoryFlow?: MemoryFlowEventSink;
+ abortSignal?: AbortSignal;
startPhase(weight: number): IngestJobPhase;
}
diff --git a/packages/cli/src/context/llm/ai-sdk-runtime.ts b/packages/cli/src/context/llm/ai-sdk-runtime.ts
index f5752355..d5a60c7b 100644
--- a/packages/cli/src/context/llm/ai-sdk-runtime.ts
+++ b/packages/cli/src/context/llm/ai-sdk-runtime.ts
@@ -3,7 +3,9 @@ import type { KtxLlmProvider } from '../../llm/types.js';
import { generateText, Output, stepCountIs, type FlexibleSchema, type TelemetrySettings, type ToolSet } from 'ai';
import type { z } from 'zod';
import { noopLogger, type KtxLogger } from '../../context/core/config.js';
+import { isAbortError } from '../core/abort.js';
import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from './debug-request-recorder.js';
+import type { RateLimitGovernor, RateLimitProvider, RateLimitSignal } from './rate-limit-governor.js';
import { createAiSdkToolSet } from './runtime-tools.js';
import type {
KtxGenerateObjectInput,
@@ -40,12 +42,129 @@ export interface AiSdkKtxLlmRuntimeDeps {
telemetry?: AgentTelemetryPort;
logger?: KtxLogger;
debugRequestRecorder?: KtxLlmDebugRequestRecorder;
+ rateLimitGovernor?: Pick;
}
function hasTools(tools: Record): boolean {
return Object.keys(tools).length > 0;
}
+function modelProviderName(model: unknown): RateLimitProvider {
+ const provider = (model as { provider?: string }).provider ?? '';
+ return provider.includes('vertex') || provider.includes('google') ? 'vertex' : 'anthropic-api';
+}
+
+interface HeaderLimitPair {
+ limit: string;
+ remaining: string;
+ rateLimitType: string;
+}
+
+const RATE_LIMIT_HEADER_PAIRS: HeaderLimitPair[] = [
+ {
+ limit: 'anthropic-ratelimit-requests-limit',
+ remaining: 'anthropic-ratelimit-requests-remaining',
+ rateLimitType: 'rpm',
+ },
+ {
+ limit: 'anthropic-ratelimit-tokens-limit',
+ remaining: 'anthropic-ratelimit-tokens-remaining',
+ rateLimitType: 'tpm',
+ },
+ {
+ limit: 'anthropic-ratelimit-input-tokens-limit',
+ remaining: 'anthropic-ratelimit-input-tokens-remaining',
+ rateLimitType: 'itpm',
+ },
+ {
+ limit: 'anthropic-ratelimit-output-tokens-limit',
+ remaining: 'anthropic-ratelimit-output-tokens-remaining',
+ rateLimitType: 'otpm',
+ },
+ {
+ limit: 'x-ratelimit-limit-requests',
+ remaining: 'x-ratelimit-remaining-requests',
+ rateLimitType: 'rpm',
+ },
+ {
+ limit: 'x-ratelimit-limit-tokens',
+ remaining: 'x-ratelimit-remaining-tokens',
+ rateLimitType: 'tpm',
+ },
+];
+
+function normalizeHeaders(headers: unknown): Record {
+ if (!headers || typeof headers !== 'object') {
+ return {};
+ }
+ const get = (headers as { get?: unknown }).get;
+ if (typeof get === 'function') {
+ const out: Record = {};
+ for (const pair of RATE_LIMIT_HEADER_PAIRS) {
+ const limit = get.call(headers, pair.limit);
+ const remaining = get.call(headers, pair.remaining);
+ if (typeof limit === 'string') out[pair.limit] = limit;
+ if (typeof remaining === 'string') out[pair.remaining] = remaining;
+ }
+ return out;
+ }
+ return Object.fromEntries(
+ Object.entries(headers as Record)
+ .filter((entry): entry is [string, string | number] => typeof entry[1] === 'string' || typeof entry[1] === 'number')
+ .map(([key, value]) => [key.toLowerCase(), String(value)]),
+ );
+}
+
+function numericHeader(headers: Record, key: string): number | undefined {
+ const value = Number(headers[key]);
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
+}
+
+function utilizationForPair(headers: Record, pair: HeaderLimitPair): number | undefined {
+ const limit = numericHeader(headers, pair.limit);
+ const remaining = numericHeader(headers, pair.remaining);
+ if (limit === undefined || remaining === undefined || limit <= 0) {
+ return undefined;
+ }
+ return 1 - Math.min(limit, remaining) / limit;
+}
+
+function aiSdkHeaderRateLimitSignal(provider: RateLimitProvider, result: unknown): RateLimitSignal | undefined {
+ const headers = normalizeHeaders((result as { response?: { headers?: unknown } }).response?.headers);
+ let best: { utilization: number; rateLimitType: string } | undefined;
+ for (const pair of RATE_LIMIT_HEADER_PAIRS) {
+ const utilization = utilizationForPair(headers, pair);
+ if (utilization === undefined) {
+ continue;
+ }
+ if (!best || utilization > best.utilization) {
+ best = { utilization, rateLimitType: pair.rateLimitType };
+ }
+ }
+ if (!best) {
+ return undefined;
+ }
+ return {
+ provider,
+ status: 'allowed',
+ rateLimitType: best.rateLimitType,
+ utilization: Number(best.utilization.toFixed(4)),
+ };
+}
+
+function retryAfterMs(error: unknown): number | undefined {
+ const value = (error as { retryAfter?: unknown }).retryAfter;
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
+ return value < 1_000 ? value * 1_000 : value;
+ }
+ return undefined;
+}
+
+function isAiSdkRateLimitError(error: unknown): boolean {
+ const record = error as { name?: string; statusCode?: number; status?: number };
+ return record.name === 'TooManyRequestsError' || record.statusCode === 429 || record.status === 429;
+}
+
export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
private readonly logger: KtxLogger;
@@ -53,6 +172,41 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
this.logger = deps.logger ?? noopLogger;
}
+ private async generateTextWithRateLimitRetry(
+ provider: RateLimitProvider,
+ abortSignal: AbortSignal | undefined,
+ run: () => Promise,
+ ): Promise {
+ // maxRetryAttempts() returns 1 when no governor is present or pacing is
+ // disabled, so a 429 throws immediately instead of hammering the provider
+ // with no backoff; the AI SDK's own maxRetries still handles transient 429s.
+ const maxAttempts = this.deps.rateLimitGovernor?.maxRetryAttempts() ?? 1;
+ let attempt = 0;
+ while (true) {
+ await this.deps.rateLimitGovernor?.waitForReady(abortSignal);
+ try {
+ const result = await run();
+ const signal = aiSdkHeaderRateLimitSignal(provider, result);
+ if (signal) {
+ this.deps.rateLimitGovernor?.report(signal);
+ }
+ return result;
+ } catch (error) {
+ if (isAbortError(error) || !isAiSdkRateLimitError(error) || attempt >= maxAttempts - 1) {
+ throw error;
+ }
+ attempt += 1;
+ const retryAfter = retryAfterMs(error);
+ this.deps.rateLimitGovernor?.report({
+ provider,
+ status: 'rejected',
+ rateLimitType: 'http_429',
+ ...(retryAfter !== undefined ? { retryAfterMs: retryAfter } : {}),
+ });
+ }
+ }
+ }
+
async generateText(input: KtxGenerateTextInput): Promise {
const model = this.deps.llmProvider.getModel(input.role);
if ((model as { provider?: string }).provider === 'deterministic') {
@@ -67,12 +221,13 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
});
const split = splitKtxSystemMessages(built.messages);
const startedAt = Date.now();
- const result = await generateText({
+ const request = {
model,
temperature: input.temperature ?? 0,
...(split.system ? { system: split.system } : {}),
messages: split.messages,
tools: built.tools as ToolSet,
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
...(hasTools(tools)
? {
experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({
@@ -80,7 +235,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
}),
}
: {}),
- });
+ };
+ const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request));
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) });
if (typeof result.text !== 'string') {
throw new Error('KTX LLM text generation returned no text');
@@ -101,12 +257,13 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
});
const split = splitKtxSystemMessages(built.messages);
const startedAt = Date.now();
- const result = await generateText({
+ const request = {
model,
temperature: input.temperature ?? 0,
...(split.system ? { system: split.system } : {}),
messages: split.messages,
tools: built.tools as ToolSet,
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
...(hasTools(tools)
? {
experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({
@@ -115,7 +272,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
output: Output.object({ schema: input.schema as unknown as FlexibleSchema }),
- });
+ };
+ const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), input.abortSignal, () => generateText(request));
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: toLlmTokenUsage(result.totalUsage ?? result.usage) });
if (result.output == null) {
throw new Error('KTX LLM object generation returned no output');
@@ -152,7 +310,7 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
}),
);
- const result = await generateText({
+ const request = {
model,
temperature: 0,
stopWhen: stepCountIs(params.stepBudget),
@@ -163,6 +321,7 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
...(promptMessages.system ? { system: promptMessages.system } : {}),
messages: promptMessages.messages,
tools: built.tools as ToolSet,
+ ...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
onStepFinish: async () => {
stepIndex += 1;
stepBoundariesMs.push(Date.now() - startedAt);
@@ -179,7 +338,8 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
);
}
},
- });
+ };
+ const result = await this.generateTextWithRateLimitRetry(modelProviderName(model), params.abortSignal, () => generateText(request));
return {
stopReason: 'natural',
metrics: {
@@ -190,6 +350,9 @@ export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort {
},
};
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(`[agent-runner] loop failed: ${err.message}`);
return {
diff --git a/packages/cli/src/context/llm/claude-code-runtime.ts b/packages/cli/src/context/llm/claude-code-runtime.ts
index 0c1e6881..26bd0529 100644
--- a/packages/cli/src/context/llm/claude-code-runtime.ts
+++ b/packages/cli/src/context/llm/claude-code-runtime.ts
@@ -7,8 +7,10 @@ import {
} from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { noopLogger, type KtxLogger } from '../../context/core/config.js';
+import { createAbortError, isAbortError, throwIfAborted } from '../core/abort.js';
import { createKtxClaudeCodeEnv } from './claude-code-env.js';
import { resolveClaudeCodeModel } from './claude-code-models.js';
+import type { RateLimitGovernor, RateLimitSignal } from './rate-limit-governor.js';
import { createClaudeSdkTools, mcpToolIds } from './runtime-tools.js';
import type {
KtxGenerateObjectInput,
@@ -21,7 +23,16 @@ import type {
RunLoopStopReason,
} from './runtime-port.js';
-type QueryFn = (params: Parameters[0]) => AsyncIterable;
+type QueryResult = AsyncIterable & {
+ interrupt?: () => void | Promise;
+};
+
+type QueryFn = (params: Parameters[0]) => QueryResult;
+
+interface ClaudeQueryOutcome {
+ result: SDKResultMessage;
+ rejectedRateLimitSignal?: RateLimitSignal;
+}
function claudeTokenUsage(result: SDKResultMessage): LlmTokenUsage {
const usage = (result as { usage?: { input_tokens?: number; output_tokens?: number } }).usage;
@@ -43,6 +54,7 @@ export interface ClaudeCodeKtxLlmRuntimeDeps {
query?: QueryFn;
env?: NodeJS.ProcessEnv;
logger?: KtxLogger;
+ rateLimitGovernor?: Pick;
}
const BUILTIN_TOOLS = [
@@ -157,6 +169,74 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set();
}
+const CLAUDE_RATE_LIMIT_ERROR_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i;
+
+function normalizeClaudeResetAtMs(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
+ return Math.round(value < 10_000_000_000 ? value * 1_000 : value);
+ }
+ if (typeof value === 'string') {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric > 0) {
+ return normalizeClaudeResetAtMs(numeric);
+ }
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ }
+ return undefined;
+}
+
+function isClaudeRateLimitResult(result: SDKResultMessage, rejectedSignal: RateLimitSignal | undefined): boolean {
+ const error = resultError(result);
+ if (!error) {
+ return false;
+ }
+ if (rejectedSignal?.status === 'rejected') {
+ return true;
+ }
+ const resultDetails = result as {
+ stop_reason?: unknown;
+ terminal_reason?: unknown;
+ errors?: unknown[];
+ };
+ const details = [
+ error.message,
+ resultDetails.stop_reason,
+ resultDetails.terminal_reason,
+ ...(resultDetails.errors ?? []),
+ ]
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
+ .join('\n');
+ return CLAUDE_RATE_LIMIT_ERROR_MARKERS.test(details);
+}
+
+function claudeRateLimitSignal(message: SDKMessage): RateLimitSignal | null {
+ const record = message as unknown as Record;
+ if (record.type === 'rate_limit_event') {
+ const info = record.rate_limit_info as Record | undefined;
+ if (!info) return null;
+ const rawStatus = typeof info.status === 'string' ? info.status : 'allowed';
+ const resetAtMs = normalizeClaudeResetAtMs(info.resetsAt);
+ return {
+ provider: 'claude-subscription',
+ status: rawStatus === 'rejected' ? 'rejected' : rawStatus === 'allowed_warning' ? 'warning' : 'allowed',
+ ...(resetAtMs !== undefined ? { resetAtMs } : {}),
+ ...(typeof info.rateLimitType === 'string' ? { rateLimitType: info.rateLimitType } : {}),
+ ...(typeof info.utilization === 'number' ? { utilization: info.utilization } : {}),
+ };
+ }
+ if (record.subtype === 'api_retry' || record.type === 'api_retry') {
+ const retryDelayMs = typeof record.retry_delay_ms === 'number' ? record.retry_delay_ms : undefined;
+ return {
+ provider: 'claude-subscription',
+ status: 'warning',
+ ...(retryDelayMs !== undefined ? { retryAfterMs: retryDelayMs } : {}),
+ rateLimitType: 'api_retry',
+ };
+ }
+ return null;
+}
+
function managedMcpSettings(serverNames: string[]): NonNullable {
return {
allowManagedMcpServersOnly: true,
@@ -217,21 +297,63 @@ async function collectResult(params: {
allowedToolIds: Set;
expectedMcpServerNames: Set;
onAssistantTurn?: () => Promise;
-}): Promise {
+ rateLimitGovernor?: Pick;
+ abortSignal?: AbortSignal;
+}): Promise {
let result: SDKResultMessage | undefined;
- for await (const message of params.query({ prompt: params.prompt, options: params.options })) {
- assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames);
- if (countsAsAssistantTurn(message)) {
- await params.onAssistantTurn?.();
- }
- if (isResult(message)) {
- result = message;
+ let rejectedRateLimitSignal: RateLimitSignal | undefined;
+ throwIfAborted(params.abortSignal);
+ await params.rateLimitGovernor?.waitForReady(params.abortSignal);
+ throwIfAborted(params.abortSignal);
+ const queryResult = params.query({ prompt: params.prompt, options: params.options });
+ const onAbort = () => {
+ void Promise.resolve(queryResult.interrupt?.()).catch(() => undefined);
+ };
+ params.abortSignal?.addEventListener('abort', onAbort, { once: true });
+ try {
+ for await (const message of queryResult) {
+ throwIfAborted(params.abortSignal);
+ const rateLimitSignal = claudeRateLimitSignal(message);
+ if (rateLimitSignal) {
+ if (rateLimitSignal.status === 'rejected') {
+ rejectedRateLimitSignal = rateLimitSignal;
+ }
+ params.rateLimitGovernor?.report(rateLimitSignal);
+ }
+ assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames);
+ if (countsAsAssistantTurn(message)) {
+ await params.onAssistantTurn?.();
+ }
+ if (isResult(message)) {
+ result = message;
+ }
}
+ } finally {
+ params.abortSignal?.removeEventListener('abort', onAbort);
+ }
+ if (params.abortSignal?.aborted) {
+ throw createAbortError();
}
if (!result) {
throw new Error('Claude Code query returned no result message');
}
- return result;
+ return {
+ result,
+ ...(rejectedRateLimitSignal ? { rejectedRateLimitSignal } : {}),
+ };
+}
+
+async function collectResultWithRateLimitRetry(params: Parameters[0]): Promise {
+ // maxRetryAttempts() returns 1 when no governor is present or pacing is
+ // disabled, so a rate-limited result surfaces without an extra query; the
+ // Claude Code SDK applies its own backoff for transient rejections.
+ const maxAttempts = params.rateLimitGovernor?.maxRetryAttempts() ?? 1;
+ for (let attempt = 0; ; attempt += 1) {
+ const outcome = await collectResult(params);
+ if (!isClaudeRateLimitResult(outcome.result, outcome.rejectedRateLimitSignal) || attempt >= maxAttempts - 1) {
+ return outcome.result;
+ }
+ }
}
export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
@@ -252,12 +374,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
tools: input.tools,
});
const startedAt = Date.now();
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: this.runQuery,
prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'),
options,
allowedToolIds: new Set(mcpToolIds(input.tools ?? {})),
expectedMcpServerNames: expectedMcpServerNames(input.tools),
+ rateLimitGovernor: this.deps.rateLimitGovernor,
+ abortSignal: input.abortSignal,
});
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: claudeTokenUsage(result) });
const error = resultError(result);
@@ -289,12 +413,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
outputFormat: { type: 'json_schema' as const, schema: jsonSchema(input.schema as z.ZodType) },
};
const startedAt = Date.now();
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: this.runQuery,
prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'),
options,
allowedToolIds: new Set([...mcpToolIds(input.tools ?? {}), STRUCTURED_OUTPUT_TOOL_NAME]),
expectedMcpServerNames: expectedMcpServerNames(input.tools),
+ rateLimitGovernor: this.deps.rateLimitGovernor,
+ abortSignal: input.abortSignal,
});
input.onMetrics?.({ totalMs: Date.now() - startedAt, usage: claudeTokenUsage(result) });
const error = resultError(result);
@@ -319,12 +445,14 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
maxTurns: params.stepBudget,
tools: params.toolSet,
});
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: this.runQuery,
prompt: params.userPrompt,
options: { ...options, systemPrompt: params.systemPrompt },
allowedToolIds: new Set(mcpToolIds(params.toolSet)),
expectedMcpServerNames: expectedMcpServerNames(params.toolSet),
+ rateLimitGovernor: this.deps.rateLimitGovernor,
+ abortSignal: params.abortSignal,
onAssistantTurn: async () => {
stepIndex += 1;
stepBoundariesMs.push(Date.now() - startedAt);
@@ -355,6 +483,9 @@ export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort {
},
};
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
const err = error instanceof Error ? error : new Error(String(error));
return {
stopReason: 'error',
@@ -388,7 +519,7 @@ export async function runClaudeCodeAuthProbe(input: {
env: input.env,
maxTurns: 1,
});
- const result = await collectResult({
+ const result = await collectResultWithRateLimitRetry({
query: input.query ?? defaultQuery,
prompt: 'Reply with exactly: ok',
options,
diff --git a/packages/cli/src/context/llm/codex-runtime.ts b/packages/cli/src/context/llm/codex-runtime.ts
index 3535072b..2958b3f8 100644
--- a/packages/cli/src/context/llm/codex-runtime.ts
+++ b/packages/cli/src/context/llm/codex-runtime.ts
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { noopLogger, type KtxLogger } from '../core/config.js';
+import { isAbortError, linkAbortSignal } from '../core/abort.js';
import { isCompletedAgentStep, summarizeCodexExecEvents, type CodexExecEventSummary } from './codex-exec-events.js';
import {
startCodexRuntimeMcpServer,
@@ -8,6 +9,7 @@ import {
import { resolveCodexModel } from './codex-models.js';
import { buildCodexRuntimeConfig } from './codex-runtime-config.js';
import { CodexSdkCliRunner, type CodexSdkRunner } from './codex-sdk-runner.js';
+import type { RateLimitGovernor } from './rate-limit-governor.js';
import type {
KtxGenerateObjectInput,
KtxGenerateTextInput,
@@ -24,6 +26,7 @@ export interface CodexKtxLlmRuntimeDeps {
runner?: CodexSdkRunner;
startMcpServer?: (input: { projectDir: string; toolSet: KtxRuntimeToolSet }) => Promise;
logger?: KtxLogger;
+ rateLimitGovernor?: Pick;
}
function modelForRole(modelSlots: CodexKtxLlmRuntimeDeps['modelSlots'], role: string): string {
@@ -159,6 +162,12 @@ function runtimeToolNames(toolSet: KtxRuntimeToolSet | undefined): string[] {
return Object.values(toolSet ?? {}).map((descriptor) => descriptor.name);
}
+const CODEX_RATE_LIMIT_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|temporarily overloaded/i;
+
+function isCodexRateLimitError(error: Error | undefined): boolean {
+ return !!error && CODEX_RATE_LIMIT_MARKERS.test(error.message);
+}
+
export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
private readonly runner: CodexSdkRunner;
private readonly logger: KtxLogger;
@@ -168,6 +177,37 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
this.logger = deps.logger ?? noopLogger;
}
+ private async runWithRateLimitRetry(
+ abortSignal: AbortSignal | undefined,
+ run: () => Promise,
+ getError: (result: T) => Error | undefined,
+ ): Promise {
+ // maxRetryAttempts() returns 1 when no governor is present or pacing is
+ // disabled, so an opaque rate-limit failure surfaces on the first attempt
+ // instead of being retried with no backoff.
+ const maxAttempts = this.deps.rateLimitGovernor?.maxRetryAttempts() ?? 1;
+ for (let attempt = 0; ; attempt += 1) {
+ await this.deps.rateLimitGovernor?.waitForReady(abortSignal);
+ const lastAttempt = attempt >= maxAttempts - 1;
+ try {
+ const result = await run();
+ const error = getError(result);
+ if (!isCodexRateLimitError(error) || lastAttempt) {
+ return result;
+ }
+ } catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
+ const err = error instanceof Error ? error : new Error(String(error));
+ if (!isCodexRateLimitError(err) || lastAttempt) {
+ throw error;
+ }
+ }
+ this.deps.rateLimitGovernor?.report({ provider: 'codex', status: 'rejected', rateLimitType: 'opaque' });
+ }
+ }
+
async generateText(input: KtxGenerateTextInput): Promise {
const startedAt = Date.now();
const model = modelForRole(this.deps.modelSlots, input.role);
@@ -190,18 +230,26 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
});
- const collected = await collectEvents(
- await this.runner.runStreamed({
- projectDir: this.deps.projectDir,
- model,
- prompt: promptWithSystem(input.system, input.prompt),
- configOverrides: config.configOverrides,
- env: config.env,
- }),
+ const result = await this.runWithRateLimitRetry(
+ input.abortSignal,
+ async () => {
+ const collected = await collectEvents(
+ await this.runner.runStreamed({
+ projectDir: this.deps.projectDir,
+ model,
+ prompt: promptWithSystem(input.system, input.prompt),
+ configOverrides: config.configOverrides,
+ env: config.env,
+ ...(input.abortSignal ? { signal: input.abortSignal } : {}),
+ }),
+ );
+ const summary = summarizeCodexExecEvents(collected.events, { startedAt });
+ return { collected, summary };
+ },
+ ({ collected, summary }) => summaryError(summary, collected.streamError),
);
- const summary = summarizeCodexExecEvents(collected.events, { startedAt });
- input.onMetrics?.(metrics(summary, startedAt));
- return assertSuccessfulText(summary, collected.streamError);
+ input.onMetrics?.(metrics(result.summary, startedAt));
+ return assertSuccessfulText(result.summary, result.collected.streamError);
} finally {
await mcp?.close();
}
@@ -231,19 +279,27 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
});
- const collected = await collectEvents(
- await this.runner.runStreamed({
- projectDir: this.deps.projectDir,
- model,
- prompt: promptWithSystem(input.system, input.prompt),
- configOverrides: config.configOverrides,
- env: config.env,
- outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record,
- }),
+ const result = await this.runWithRateLimitRetry(
+ input.abortSignal,
+ async () => {
+ const collected = await collectEvents(
+ await this.runner.runStreamed({
+ projectDir: this.deps.projectDir,
+ model,
+ prompt: promptWithSystem(input.system, input.prompt),
+ configOverrides: config.configOverrides,
+ env: config.env,
+ outputSchema: z.toJSONSchema(input.schema, { target: 'draft-7' }) as Record,
+ ...(input.abortSignal ? { signal: input.abortSignal } : {}),
+ }),
+ );
+ const summary = summarizeCodexExecEvents(collected.events, { startedAt });
+ return { collected, summary };
+ },
+ ({ collected, summary }) => summaryError(summary, collected.streamError),
);
- const summary = summarizeCodexExecEvents(collected.events, { startedAt });
- input.onMetrics?.(metrics(summary, startedAt));
- return parseStructuredOutput(input.schema, assertSuccessfulText(summary, collected.streamError));
+ input.onMetrics?.(metrics(result.summary, startedAt));
+ return parseStructuredOutput(input.schema, assertSuccessfulText(result.summary, result.collected.streamError));
} finally {
await mcp?.close();
}
@@ -272,7 +328,6 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
}
: {}),
});
- const abortController = new AbortController();
const onStep = async (stepIndex: number): Promise => {
try {
await params.onStepFinish?.({ stepIndex, stepBudget: params.stepBudget });
@@ -282,31 +337,50 @@ export class CodexKtxLlmRuntime implements KtxLlmRuntimePort {
);
}
};
- const collected = await collectEvents(
- await this.runner.runStreamed({
- projectDir: this.deps.projectDir,
- model,
- prompt: promptWithSystem(params.systemPrompt, params.userPrompt),
- configOverrides: config.configOverrides,
- env: config.env,
- signal: abortController.signal,
- }),
- { stepBudget: params.stepBudget, abortController, onStep },
+ const result = await this.runWithRateLimitRetry(
+ params.abortSignal,
+ async () => {
+ const linked = linkAbortSignal(params.abortSignal);
+ const abortController = linked.controller;
+ try {
+ const collected = await collectEvents(
+ await this.runner.runStreamed({
+ projectDir: this.deps.projectDir,
+ model,
+ prompt: promptWithSystem(params.systemPrompt, params.userPrompt),
+ configOverrides: config.configOverrides,
+ env: config.env,
+ signal: abortController.signal,
+ }),
+ { stepBudget: params.stepBudget, abortController, onStep },
+ );
+ const summary = summarizeCodexExecEvents(collected.events, { startedAt });
+ return { collected, summary };
+ } finally {
+ linked.dispose();
+ }
+ },
+ ({ collected, summary }) => summaryError(summary, collected.streamError),
);
- const summary = summarizeCodexExecEvents(collected.events, { startedAt });
- const error = summaryError(summary, collected.streamError);
- const stopReason = collected.budgetExceeded ? 'budget' : error ? 'error' : summary.stopReason;
+ const error = summaryError(result.summary, result.collected.streamError);
+ if (isAbortError(error)) {
+ throw error;
+ }
+ const stopReason = result.collected.budgetExceeded ? 'budget' : error ? 'error' : result.summary.stopReason;
return {
stopReason,
...(stopReason === 'error' && error ? { error } : {}),
metrics: {
totalMs: Date.now() - startedAt,
- usage: summary.usage,
- stepCount: summary.stepCount,
- stepBoundariesMs: summary.stepBoundariesMs,
+ usage: result.summary.usage,
+ stepCount: result.summary.stepCount,
+ stepBoundariesMs: result.summary.stepBoundariesMs,
},
};
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
const err = error instanceof Error ? error : new Error(String(error));
return {
stopReason: 'error',
diff --git a/packages/cli/src/context/llm/local-config.ts b/packages/cli/src/context/llm/local-config.ts
index 58bd29a5..4c2502d1 100644
--- a/packages/cli/src/context/llm/local-config.ts
+++ b/packages/cli/src/context/llm/local-config.ts
@@ -6,16 +6,28 @@ import type { KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from '../project/
import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js';
import { ClaudeCodeKtxLlmRuntime } from './claude-code-runtime.js';
import { CodexKtxLlmRuntime } from './codex-runtime.js';
+import type { RateLimitGovernor } from './rate-limit-governor.js';
import type { KtxLlmRuntimePort } from './runtime-port.js';
+type ClaudeCodeRuntimeDeps = ConstructorParameters[0] & {
+ rateLimitGovernor?: RateLimitGovernor;
+};
+type CodexRuntimeDeps = ConstructorParameters[0] & {
+ rateLimitGovernor?: RateLimitGovernor;
+};
+type AiSdkRuntimeDeps = ConstructorParameters[0] & {
+ rateLimitGovernor?: RateLimitGovernor;
+};
+
interface LocalConfigDeps {
env?: NodeJS.ProcessEnv;
projectDir?: string;
+ rateLimitGovernor?: RateLimitGovernor;
createKtxLlmProvider?: typeof createKtxLlmProvider;
createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider;
- createClaudeCodeRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort;
- createCodexRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort;
- createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort;
+ createClaudeCodeRuntime?: (deps: ClaudeCodeRuntimeDeps) => KtxLlmRuntimePort;
+ createCodexRuntime?: (deps: CodexRuntimeDeps) => KtxLlmRuntimePort;
+ createAiSdkRuntime?: (deps: AiSdkRuntimeDeps) => KtxLlmRuntimePort;
}
function resolveOptional(value: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
@@ -129,6 +141,7 @@ export function createLocalKtxLlmRuntimeFromConfig(
projectDir,
modelSlots: resolved.modelSlots,
env: deps.env,
+ rateLimitGovernor: deps.rateLimitGovernor,
});
}
if (resolved.backend === 'codex') {
@@ -139,10 +152,14 @@ export function createLocalKtxLlmRuntimeFromConfig(
return (deps.createCodexRuntime ?? ((runtimeDeps) => new CodexKtxLlmRuntime(runtimeDeps)))({
projectDir,
modelSlots: resolved.modelSlots,
+ rateLimitGovernor: deps.rateLimitGovernor,
});
}
const llmProvider = (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved);
- return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ llmProvider });
+ return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({
+ llmProvider,
+ rateLimitGovernor: deps.rateLimitGovernor,
+ });
}
export function resolveLocalKtxEmbeddingConfig(
diff --git a/packages/cli/src/context/llm/rate-limit-governor.ts b/packages/cli/src/context/llm/rate-limit-governor.ts
new file mode 100644
index 00000000..909e4c44
--- /dev/null
+++ b/packages/cli/src/context/llm/rate-limit-governor.ts
@@ -0,0 +1,387 @@
+import { createAbortError, throwIfAborted } from '../core/abort.js';
+
+export type RateLimitProvider = 'claude-subscription' | 'anthropic-api' | 'vertex' | 'codex';
+type RateLimitSignalStatus = 'allowed' | 'warning' | 'rejected';
+
+export interface RateLimitSignal {
+ provider: RateLimitProvider;
+ status: RateLimitSignalStatus;
+ resetAtMs?: number;
+ retryAfterMs?: number;
+ utilization?: number;
+ rateLimitType?: string;
+}
+
+export interface RateLimitRetryConfig {
+ maxAttempts: number;
+ baseDelayMs: number;
+ maxDelayMs: number;
+ jitter: boolean;
+}
+
+export interface RateLimitGovernorConfig {
+ enabled: boolean;
+ maxConcurrency: number;
+ throttleThreshold: number;
+ minConcurrencyUnderPressure: number;
+ maxWaitMs?: number;
+ waitStateTickMs: number;
+ retry: RateLimitRetryConfig;
+}
+
+export type RateLimitWaitState =
+ | {
+ kind: 'rate_limit_observed';
+ provider: RateLimitProvider;
+ status: RateLimitSignalStatus;
+ rateLimitType?: string;
+ resetAtMs?: number;
+ retryAfterMs?: number;
+ utilization?: number;
+ }
+ | {
+ kind: 'concurrency_adjusted';
+ provider: RateLimitProvider;
+ from: number;
+ to: number;
+ reason: string;
+ rateLimitType?: string;
+ utilization?: number;
+ }
+ | {
+ kind: 'wait_started' | 'wait_tick' | 'wait_finished';
+ provider: RateLimitProvider;
+ rateLimitType?: string;
+ resumeAtMs: number;
+ remainingMs: number;
+ };
+
+export interface RateLimitGovernorDeps {
+ now?: () => number;
+ sleep?: (ms: number, signal?: AbortSignal) => Promise;
+ random?: () => number;
+}
+
+export type RateLimitRelease = () => void;
+type Subscriber = (state: RateLimitWaitState) => void;
+
+const defaultSleep = (ms: number, signal?: AbortSignal): Promise =>
+ new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(createAbortError());
+ return;
+ }
+ const timeout = setTimeout(resolve, ms);
+ signal?.addEventListener(
+ 'abort',
+ () => {
+ clearTimeout(timeout);
+ reject(createAbortError());
+ },
+ { once: true },
+ );
+ });
+
+export function createRateLimitGovernorConfig(
+ input: Partial & { retry?: Partial } = {},
+): RateLimitGovernorConfig {
+ return {
+ enabled: input.enabled ?? true,
+ maxConcurrency: input.maxConcurrency ?? 1,
+ throttleThreshold: input.throttleThreshold ?? 0.8,
+ minConcurrencyUnderPressure: input.minConcurrencyUnderPressure ?? 1,
+ ...(input.maxWaitMs !== undefined ? { maxWaitMs: input.maxWaitMs } : {}),
+ waitStateTickMs: input.waitStateTickMs ?? 1_000,
+ retry: {
+ maxAttempts: input.retry?.maxAttempts ?? 6,
+ baseDelayMs: input.retry?.baseDelayMs ?? 1_000,
+ maxDelayMs: input.retry?.maxDelayMs ?? 60_000,
+ jitter: input.retry?.jitter ?? true,
+ },
+ };
+}
+
+export class RateLimitGovernor {
+ private readonly now: () => number;
+ private readonly sleep: (ms: number, signal?: AbortSignal) => Promise;
+ private readonly random: () => number;
+ private readonly subscribers = new Set();
+ private waiters: Array<() => void> = [];
+ private active = 0;
+ private effectiveLimit: number;
+ private pausedUntilMs: number | null = null;
+ private pausedProvider: RateLimitProvider | null = null;
+ private pausedRateLimitType: string | undefined;
+ private pausedTickMs: number | null = null;
+ private opaqueAttempts = new Map();
+ private pauseGeneration = 0;
+ private visibleWaitAbort: AbortController | null = null;
+
+ constructor(
+ private readonly config: RateLimitGovernorConfig,
+ deps: RateLimitGovernorDeps = {},
+ ) {
+ this.now = deps.now ?? Date.now;
+ this.sleep = deps.sleep ?? defaultSleep;
+ this.random = deps.random ?? Math.random;
+ this.effectiveLimit = Math.max(1, config.maxConcurrency);
+ }
+
+ currentLimit(): number {
+ return this.config.enabled ? this.effectiveLimit : this.config.maxConcurrency;
+ }
+
+ /**
+ * Total attempts a runtime should make for a single rate-limited LLM call,
+ * including the first try. Returns 1 (no outer retry) when pacing is disabled:
+ * the outer retry loop only exists to cooperate with this governor's pause, so
+ * without active pacing there is no backoff to apply and the backend's own
+ * retry handles transient rejections.
+ */
+ maxRetryAttempts(): number {
+ return this.config.enabled ? Math.max(1, this.config.retry.maxAttempts) : 1;
+ }
+
+ activeSlots(): number {
+ return this.active;
+ }
+
+ subscribe(cb: Subscriber): () => void {
+ this.subscribers.add(cb);
+ if (this.pausedUntilMs !== null) {
+ this.startVisibleWaitTicker();
+ }
+ return () => {
+ this.subscribers.delete(cb);
+ if (this.subscribers.size === 0) {
+ this.stopVisibleWaitTicker();
+ this.wakeWaiters();
+ }
+ };
+ }
+
+ report(signal: RateLimitSignal): void {
+ if (!this.config.enabled) {
+ return;
+ }
+ this.emit({
+ kind: 'rate_limit_observed',
+ provider: signal.provider,
+ status: signal.status,
+ ...(signal.rateLimitType ? { rateLimitType: signal.rateLimitType } : {}),
+ ...(signal.resetAtMs !== undefined ? { resetAtMs: signal.resetAtMs } : {}),
+ ...(signal.retryAfterMs !== undefined ? { retryAfterMs: signal.retryAfterMs } : {}),
+ ...(signal.utilization !== undefined ? { utilization: signal.utilization } : {}),
+ });
+
+ if (signal.status === 'rejected') {
+ this.applyPause(signal);
+ return;
+ }
+
+ if (signal.status === 'warning' || (signal.utilization ?? 0) >= this.config.throttleThreshold) {
+ this.adjustLimit(Math.max(1, this.config.minConcurrencyUnderPressure), signal, 'provider pressure');
+ return;
+ }
+
+ this.opaqueAttempts.delete(signal.provider);
+ if ((signal.utilization ?? 0) < this.config.throttleThreshold) {
+ this.adjustLimit(Math.max(1, this.config.maxConcurrency), signal, 'provider recovered');
+ }
+ }
+
+ async waitForReady(signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ if (!this.config.enabled) {
+ return;
+ }
+ await this.waitForPause(signal);
+ throwIfAborted(signal);
+ }
+
+ async acquireWorkSlot(signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ if (!this.config.enabled) {
+ this.active += 1;
+ return () => {
+ this.active -= 1;
+ };
+ }
+
+ while (true) {
+ throwIfAborted(signal);
+ await this.waitForPause(signal);
+ throwIfAborted(signal);
+ if (this.active < this.effectiveLimit) {
+ this.active += 1;
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ this.active -= 1;
+ this.wakeWaiters();
+ };
+ }
+ await this.waitForSlot(signal);
+ }
+ }
+
+ private applyPause(signal: RateLimitSignal): void {
+ const resumeAtMs = this.resumeAtMsFor(signal);
+ const boundedResumeAtMs =
+ this.config.maxWaitMs === undefined ? resumeAtMs : Math.min(resumeAtMs, this.now() + this.config.maxWaitMs);
+ if (this.pausedUntilMs === null || boundedResumeAtMs > this.pausedUntilMs) {
+ this.pausedUntilMs = boundedResumeAtMs;
+ this.pausedProvider = signal.provider;
+ this.pausedRateLimitType = signal.rateLimitType;
+ this.pausedTickMs = signal.rateLimitType === 'opaque' ? Math.max(1, boundedResumeAtMs - this.now()) : null;
+ this.emitWait('wait_started');
+ this.startVisibleWaitTicker();
+ this.wakeWaiters();
+ }
+ this.adjustLimit(Math.max(1, this.config.minConcurrencyUnderPressure), signal, 'provider rejected');
+ }
+
+ private resumeAtMsFor(signal: RateLimitSignal): number {
+ if (signal.resetAtMs !== undefined) {
+ return signal.resetAtMs;
+ }
+ if (signal.retryAfterMs !== undefined) {
+ return this.now() + signal.retryAfterMs;
+ }
+ const attempts = this.opaqueAttempts.get(signal.provider) ?? 0;
+ this.opaqueAttempts.set(signal.provider, Math.min(attempts + 1, this.config.retry.maxAttempts));
+ const base = Math.min(
+ this.config.retry.maxDelayMs,
+ this.config.retry.baseDelayMs * 2 ** Math.min(attempts, this.config.retry.maxAttempts - 1),
+ );
+ const jitterMultiplier = this.config.retry.jitter ? 0.75 + this.random() * 0.5 : 1;
+ return this.now() + Math.round(base * jitterMultiplier);
+ }
+
+ private adjustLimit(to: number, signal: RateLimitSignal, reason: string): void {
+ const bounded = Math.max(1, Math.min(this.config.maxConcurrency, to));
+ if (bounded === this.effectiveLimit) {
+ return;
+ }
+ const from = this.effectiveLimit;
+ this.effectiveLimit = bounded;
+ this.emit({
+ kind: 'concurrency_adjusted',
+ provider: signal.provider,
+ from,
+ to: bounded,
+ reason,
+ ...(signal.rateLimitType ? { rateLimitType: signal.rateLimitType } : {}),
+ ...(signal.utilization !== undefined ? { utilization: signal.utilization } : {}),
+ });
+ this.wakeWaiters();
+ }
+
+ private startVisibleWaitTicker(): void {
+ if (this.subscribers.size === 0 || this.pausedUntilMs === null) {
+ return;
+ }
+ this.stopVisibleWaitTicker();
+ const generation = (this.pauseGeneration += 1);
+ const controller = new AbortController();
+ this.visibleWaitAbort = controller;
+ void this.runVisibleWaitTicker(generation, controller.signal).catch(() => undefined);
+ }
+
+ private stopVisibleWaitTicker(): void {
+ this.visibleWaitAbort?.abort();
+ this.visibleWaitAbort = null;
+ }
+
+ private async runVisibleWaitTicker(generation: number, signal: AbortSignal): Promise {
+ while (!signal.aborted && generation === this.pauseGeneration && this.pausedUntilMs !== null) {
+ const remainingMs = this.pausedUntilMs - this.now();
+ if (remainingMs <= 0) {
+ this.finishPause(generation);
+ return;
+ }
+ this.emitWait('wait_tick');
+ await this.sleep(Math.min(this.pausedTickMs ?? this.config.waitStateTickMs, remainingMs), signal);
+ }
+ }
+
+ private finishPause(generation?: number): void {
+ if (generation !== undefined && generation !== this.pauseGeneration) {
+ return;
+ }
+ this.emitWait('wait_finished');
+ this.pausedUntilMs = null;
+ this.pausedProvider = null;
+ this.pausedRateLimitType = undefined;
+ this.pausedTickMs = null;
+ this.stopVisibleWaitTicker();
+ this.wakeWaiters();
+ }
+
+ private async waitForPause(signal?: AbortSignal): Promise {
+ throwIfAborted(signal);
+ while (this.pausedUntilMs !== null) {
+ const remainingMs = this.pausedUntilMs - this.now();
+ if (remainingMs <= 0) {
+ this.finishPause();
+ return;
+ }
+ if (this.visibleWaitAbort !== null) {
+ await this.waitForSlot(signal);
+ } else {
+ await this.sleep(Math.min(this.pausedTickMs ?? this.config.waitStateTickMs, remainingMs), signal);
+ }
+ throwIfAborted(signal);
+ }
+ }
+
+ private waitForSlot(signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ return Promise.reject(createAbortError());
+ }
+ return new Promise((resolve, reject) => {
+ const wake = () => {
+ cleanup();
+ resolve();
+ };
+ const onAbort = () => {
+ cleanup();
+ reject(createAbortError());
+ };
+ const cleanup = () => {
+ this.waiters = this.waiters.filter((candidate) => candidate !== wake);
+ signal?.removeEventListener('abort', onAbort);
+ };
+ this.waiters.push(wake);
+ signal?.addEventListener('abort', onAbort, { once: true });
+ });
+ }
+
+ private wakeWaiters(): void {
+ const waiters = this.waiters;
+ this.waiters = [];
+ for (const waiter of waiters) {
+ waiter();
+ }
+ }
+
+ private emitWait(kind: Extract): void {
+ if (this.pausedUntilMs === null || this.pausedProvider === null) {
+ return;
+ }
+ this.emit({
+ kind,
+ provider: this.pausedProvider,
+ ...(this.pausedRateLimitType ? { rateLimitType: this.pausedRateLimitType } : {}),
+ resumeAtMs: this.pausedUntilMs,
+ remainingMs: Math.max(0, this.pausedUntilMs - this.now()),
+ });
+ }
+
+ private emit(state: RateLimitWaitState): void {
+ for (const subscriber of this.subscribers) {
+ subscriber(state);
+ }
+ }
+}
diff --git a/packages/cli/src/context/llm/runtime-port.ts b/packages/cli/src/context/llm/runtime-port.ts
index db648448..9fec6208 100644
--- a/packages/cli/src/context/llm/runtime-port.ts
+++ b/packages/cli/src/context/llm/runtime-port.ts
@@ -49,6 +49,7 @@ export interface RunLoopParams {
stepBudget: number;
telemetryTags: Record;
onStepFinish?: (info: RunLoopStepInfo) => void | Promise;
+ abortSignal?: AbortSignal;
}
export interface RunLoopResult {
@@ -64,6 +65,7 @@ export interface KtxGenerateTextInput {
tools?: KtxRuntimeToolSet;
temperature?: number;
onMetrics?: (metrics: { totalMs: number; usage: LlmTokenUsage }) => void;
+ abortSignal?: AbortSignal;
}
export interface KtxGenerateObjectInput> {
@@ -74,6 +76,7 @@ export interface KtxGenerateObjectInput void;
+ abortSignal?: AbortSignal;
}
export interface KtxLlmRuntimePort {
diff --git a/packages/cli/src/context/mcp/context-tools.ts b/packages/cli/src/context/mcp/context-tools.ts
index 03cd2ad4..2d07d121 100644
--- a/packages/cli/src/context/mcp/context-tools.ts
+++ b/packages/cli/src/context/mcp/context-tools.ts
@@ -3,7 +3,13 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import type { KtxCliIo } from '../../cli-runtime.js';
import type { MemoryAgentInput } from '../../context/memory/types.js';
-import { emitTelemetryEvent, mcpTelemetrySampleRate, shouldEmitMcpTelemetry } from '../../telemetry/index.js';
+import {
+ emitTelemetryEvent,
+ mcpTelemetrySampleRate,
+ reportException,
+ shouldEmitMcpTelemetry,
+} from '../../telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from '../../telemetry/redaction-secrets.js';
import { scrubErrorClass } from '../../telemetry/scrubber.js';
import type {
KtxMcpClientInfo,
@@ -518,11 +524,26 @@ function registerParsedTool(
},
schema: TSchema,
handler: (input: z.infer, context?: KtxMcpToolHandlerContext) => Promise,
+ telemetry?: { projectDir?: string; io?: KtxCliIo },
): void {
server.registerTool(name, config, async (input, context) => {
try {
return await handler(schema.parse(input), context);
} catch (error) {
+ if (telemetry?.io) {
+ await reportException({
+ error,
+ context: { source: `mcp:${name}`, handled: true, fatal: false },
+ projectDir: telemetry.projectDir,
+ io: telemetry.io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ projectDir: telemetry.projectDir,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: process.env,
+ }),
+ });
+ }
return jsonErrorToolResult(formatToolError(error));
}
});
@@ -571,6 +592,20 @@ function instrumentMcpServer(
}
return result;
} catch (error) {
+ if (telemetry.io) {
+ await reportException({
+ error,
+ context: { source: `mcp:${name}`, handled: true, fatal: false },
+ projectDir: telemetry.projectDir,
+ io: telemetry.io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ projectDir: telemetry.projectDir,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: process.env,
+ }),
+ });
+ }
if (telemetry.io && telemetry.projectDir && shouldEmitMcpTelemetry()) {
const errorClass = scrubErrorClass(error);
await emitTelemetryEvent({
@@ -596,6 +631,7 @@ function instrumentMcpServer(
export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void {
const { ports, userContext } = deps;
+ const toolTelemetry = { projectDir: deps.projectDir, io: deps.io };
const server = instrumentMcpServer(deps.server, {
projectDir: deps.projectDir,
io: deps.io,
@@ -616,6 +652,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
connectionListSchema,
async () => jsonToolResult({ connections: await connections.list() }),
+ toolTelemetry,
);
}
@@ -640,6 +677,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
limit: input.limit,
}),
),
+ toolTelemetry,
);
registerParsedTool(
@@ -657,6 +695,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
const page = await knowledge.read({ userId: userContext.userId, key: input.key });
return page ? jsonToolResult(page) : jsonErrorToolResult(`Wiki page "${input.key}" was not found.`);
},
+ toolTelemetry,
);
}
@@ -679,6 +718,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
? jsonToolResult(source)
: jsonErrorToolResult(`Semantic-layer source "${input.sourceName}" was not found.`);
},
+ toolTelemetry,
);
registerParsedTool(
@@ -711,6 +751,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
);
return jsonToolResult(projectSlQueryResult(result, input.include));
},
+ toolTelemetry,
);
}
@@ -728,6 +769,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
entityDetailsSchema,
async (input) => jsonToolResult(await entityDetails.read(input)),
+ toolTelemetry,
);
}
@@ -745,6 +787,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
dictionarySearchSchema,
async (input) => jsonToolResult(await dictionarySearch.search(input)),
+ toolTelemetry,
);
}
@@ -762,6 +805,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
},
discoverDataSchema,
async (input) => jsonToolResult({ refs: await discover.search(input) }),
+ toolTelemetry,
);
}
@@ -791,6 +835,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
),
);
},
+ toolTelemetry,
);
}
@@ -818,6 +863,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
};
return jsonToolResult(await memoryIngest.ingest(ingestInput));
},
+ toolTelemetry,
);
registerParsedTool(
@@ -835,6 +881,7 @@ export function registerKtxContextTools(deps: RegisterKtxContextToolsDeps): void
const status = await memoryIngest.status(input.runId);
return status ? jsonToolResult(status) : jsonErrorToolResult(`Memory ingest run "${input.runId}" was not found.`);
},
+ toolTelemetry,
);
}
}
diff --git a/packages/cli/src/context/project/config.ts b/packages/cli/src/context/project/config.ts
index cbea79b6..fd7f482c 100644
--- a/packages/cli/src/context/project/config.ts
+++ b/packages/cli/src/context/project/config.ts
@@ -100,6 +100,44 @@ const workUnitsSchema = z
})
.describe('Concurrency and failure handling for ingest work units.');
+const ingestRateLimitRetrySchema = z
+ .strictObject({
+ maxAttempts: z
+ .int()
+ .positive()
+ .default(6)
+ .describe(
+ 'Maximum attempts for a single rate-limited LLM call before the failure surfaces, counting the first try. Also bounds how far opaque backoff grows for providers that do not expose a reset time.',
+ ),
+ baseDelayMs: z.int().positive().default(1_000).describe('Initial opaque retry delay in milliseconds.'),
+ maxDelayMs: z.int().positive().default(60_000).describe('Maximum opaque retry delay in milliseconds.'),
+ jitter: z.boolean().default(true).describe('When true, apply bounded jitter to opaque retry delays.'),
+ })
+ .describe('Retry policy for rate-limit responses that do not include a reset time or retry-after value.');
+
+const ingestRateLimitSchema = z
+ .strictObject({
+ enabled: z.boolean().default(true).describe('Master switch for ingest LLM rate-limit pacing and visible waits.'),
+ throttleThreshold: z
+ .number()
+ .min(0)
+ .max(1)
+ .default(0.8)
+ .describe('Provider utilization at or above which ingest throttles new work-unit starts.'),
+ minConcurrencyUnderPressure: z
+ .int()
+ .positive()
+ .default(1)
+ .describe('Effective work-unit concurrency while a provider is under rate-limit pressure.'),
+ maxWaitMs: z
+ .int()
+ .positive()
+ .optional()
+ .describe('Optional cap on a single provider reset wait. Omit to wait indefinitely until the provider reset time.'),
+ retry: ingestRateLimitRetrySchema.prefault({}).describe('Opaque retry policy for providers without reset hints.'),
+ })
+ .describe('Rate-limit pacing and wait policy for ingest LLM calls.');
+
const ingestSchema = z
.strictObject({
adapters: z
@@ -110,6 +148,7 @@ const ingestSchema = z
.prefault({ backend: 'none' })
.describe('Embedding configuration used when ingest adapters need to embed documents.'),
workUnits: workUnitsSchema.prefault({}).describe('Concurrency and failure handling for ingest work units.'),
+ rateLimit: ingestRateLimitSchema.prefault({}).describe('LLM rate-limit pacing and visible-wait policy for ingest.'),
profile: z
.union([z.boolean(), z.literal('json')])
.default(false)
diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts
index 1d9e6d6a..fc445b5e 100644
--- a/packages/cli/src/context/scan/types.ts
+++ b/packages/cli/src/context/scan/types.ts
@@ -303,9 +303,29 @@ export interface KtxTableListEntry {
kind: 'table' | 'view';
}
-interface KtxConnectorTestResult {
+export interface KtxConnectorTestResult {
success: boolean;
error?: string;
+ /**
+ * The original error thrown by the driver, preserved unflattened so the
+ * connection-test path can re-throw it. Keeping the real error object lets
+ * telemetry record the driver's actual error class (e.g. `ConnectionError`)
+ * and `.code` (e.g. `ELOGIN`) instead of collapsing every failure to `Error`.
+ */
+ cause?: unknown;
+}
+
+/**
+ * Single source of truth for a failed connector test result. Captures the
+ * driver's message for display while preserving the original error as `cause`
+ * so callers can surface its real class and code.
+ */
+export function connectorTestFailure(error: unknown): KtxConnectorTestResult {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ cause: error,
+ };
}
export interface KtxScanConnector {
diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts
index ad5ba270..319c3d1b 100644
--- a/packages/cli/src/ingest.ts
+++ b/packages/cli/src/ingest.ts
@@ -78,6 +78,7 @@ export interface KtxIngestDeps {
readReportFile?: typeof readIngestReportSnapshotFile;
renderStoredMemoryFlow?: typeof renderMemoryFlowTui;
startLiveMemoryFlow?: typeof startLiveMemoryFlowTui;
+ abortSignal?: AbortSignal;
env?: NodeJS.ProcessEnv;
localIngestOptions?: Pick<
RunLocalIngestOptions,
@@ -93,6 +94,23 @@ export interface KtxIngestDeps {
runtimeIo?: KtxIngestIo;
}
+function createCliAbortSignal(): { signal: AbortSignal; dispose: () => void } {
+ const controller = new AbortController();
+ let interrupted = false;
+ const onSigint = () => {
+ if (interrupted) {
+ process.exit(130);
+ }
+ interrupted = true;
+ controller.abort(new DOMException('Aborted', 'AbortError'));
+ };
+ process.on('SIGINT', onSigint);
+ return {
+ signal: controller.signal,
+ dispose: () => process.off('SIGINT', onSigint),
+ };
+}
+
const REPORT_SOURCE_LABELS = new Map([
['live-database', 'Database schema'],
['historic-sql', 'Query history'],
@@ -364,6 +382,12 @@ function plainIngestEventProgress(
message: event.message,
...(event.transient !== undefined ? { transient: event.transient } : {}),
};
+ case 'rate_limit_wait':
+ return {
+ percent: 50,
+ message: `Rate-limited (${event.provider}${event.rateLimitType ? ` ${event.rateLimitType}` : ''}); resuming in ${Math.ceil(event.remainingMs / 1_000)}s`,
+ transient: true,
+ };
case 'work_unit_started': {
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
const ordinal = workUnitOrdinalThrough(snapshot, eventIndex, event.unitKey);
@@ -750,6 +774,8 @@ export async function runKtxIngest(
);
plainProgress?.start();
structuredProgress?.start();
+ const cliAbort = deps.abortSignal ? null : createCliAbortSignal();
+ const abortSignal = deps.abortSignal ?? cliAbort?.signal;
let result: LocalMetabaseFanoutResult;
try {
result = await executeMetabaseFanout({
@@ -763,6 +789,7 @@ export async function runKtxIngest(
embeddingProvider,
...(memoryFlow ? { memoryFlow } : {}),
...(progress ? { progress } : {}),
+ ...(abortSignal ? { abortSignal } : {}),
});
plainProgress?.flush();
if (args.outputMode === 'json') {
@@ -772,6 +799,7 @@ export async function runKtxIngest(
}
} finally {
plainProgress?.flush();
+ cliAbort?.dispose();
}
return result.status === 'all_failed' ? 1 : 0;
}
@@ -820,6 +848,8 @@ export async function runKtxIngest(
plainProgress?.start();
structuredProgress?.start();
+ const cliAbort = deps.abortSignal ? null : createCliAbortSignal();
+ const abortSignal = deps.abortSignal ?? cliAbort?.signal;
try {
const result = await executeLocalIngest({
@@ -836,6 +866,7 @@ export async function runKtxIngest(
embeddingProvider,
...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}),
...(memoryFlow ? { memoryFlow } : {}),
+ ...(abortSignal ? { abortSignal } : {}),
});
if (shouldUseLiveViz && memoryFlow) {
latestMemoryFlowSnapshot = finalRunMemoryFlowInput(memoryFlow.snapshot(), result.report);
@@ -854,6 +885,7 @@ export async function runKtxIngest(
} finally {
plainProgress?.flush();
liveTui?.close();
+ cliAbort?.dispose();
}
}
diff --git a/packages/cli/src/public-ingest.ts b/packages/cli/src/public-ingest.ts
index 44a2b024..07f805b8 100644
--- a/packages/cli/src/public-ingest.ts
+++ b/packages/cli/src/public-ingest.ts
@@ -23,7 +23,8 @@ import type { KtxScanArgs, KtxScanDeps } from './scan.js';
import type { KtxTableRef } from './context/scan/types.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
-import { emitProjectStackSnapshot, emitTelemetryEvent } from './telemetry/index.js';
+import { emitProjectStackSnapshot, emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail } from './telemetry/scrubber.js';
profileMark('module:public-ingest');
@@ -1119,30 +1120,63 @@ export async function runKtxPublicIngest(
feature,
});
} catch (error) {
+ await reportException({
+ error,
+ context: { source: 'ingest runtime', handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.targetConnectionId,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: deps.env ?? process.env,
+ }),
+ });
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
const { runContextBuild } = await import('./context-build-view.js');
const contextBuild = deps.runContextBuild ?? runContextBuild;
- const result = await contextBuild(
- project,
- {
+ try {
+ const result = await contextBuild(
+ project,
+ {
+ projectDir: args.projectDir,
+ ...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}),
+ all: args.all,
+ entrypoint: 'ingest',
+ inputMode: args.inputMode,
+ ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}),
+ ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
+ ...(args.scanMode ? { scanMode: args.scanMode } : {}),
+ ...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}),
+ ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
+ ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
+ },
+ io,
+ );
+ return result.exitCode;
+ } catch (error) {
+ await reportException({
+ error,
+ context: { source: 'ingest context-build', handled: true, fatal: false },
projectDir: args.projectDir,
- ...(args.targetConnectionId ? { targetConnectionId: args.targetConnectionId } : {}),
- all: args.all,
- entrypoint: 'ingest',
- inputMode: args.inputMode,
- ...(args.queryHistory ? { queryHistory: args.queryHistory } : {}),
- ...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
- ...(args.scanMode ? { scanMode: args.scanMode } : {}),
- ...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}),
- ...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
- ...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
- },
- io,
- );
- return result.exitCode;
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.targetConnectionId,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: deps.env ?? process.env,
+ }),
+ });
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
+ return 1;
+ }
}
const plan = buildPublicIngestPlan(project, args);
diff --git a/packages/cli/src/reveal-password-prompt.ts b/packages/cli/src/reveal-password-prompt.ts
new file mode 100644
index 00000000..3fe3ed66
--- /dev/null
+++ b/packages/cli/src/reveal-password-prompt.ts
@@ -0,0 +1,93 @@
+import { styleText } from 'node:util';
+import { PasswordPrompt, type PasswordOptions } from '@clack/core';
+import { S_BAR, S_BAR_END, S_PASSWORD_MASK, settings, symbol } from '@clack/prompts';
+
+// How many trailing characters of a pasted secret to leave visible so the user
+// can confirm what landed (e.g. `••••••a1b2`). Kept small on purpose.
+const REVEAL_TAIL_COUNT = 4;
+
+/**
+ * Mask every character of `userInput` except the last `tail`, but only reveal the
+ * tail once the secret is long enough that the hidden portion still dominates
+ * (`length > tail * 2`). Short secrets stay fully masked so we never expose most
+ * of a small value. The returned string keeps the same code-unit length as the
+ * input so clack's cursor slicing in `userInputWithCursor` stays aligned.
+ *
+ * @internal
+ */
+export function maskRevealingTail(userInput: string, maskChar: string, tail: number): string {
+ const revealLength = userInput.length > tail * 2 ? tail : 0;
+ const hiddenLength = userInput.length - revealLength;
+ return maskChar.repeat(hiddenLength) + userInput.slice(hiddenLength);
+}
+
+class RevealTailPasswordPrompt extends PasswordPrompt {
+ readonly #maskChar: string;
+ readonly #tail: number;
+
+ constructor(options: PasswordOptions & { tail: number }) {
+ super(options);
+ this.#maskChar = options.mask ?? S_PASSWORD_MASK;
+ this.#tail = options.tail;
+ }
+
+ override get masked(): string {
+ return maskRevealingTail(this.userInput, this.#maskChar, this.#tail);
+ }
+}
+
+// Reproduces the @clack/prompts password frame (pinned to the installed version)
+// so this prompt is visually identical to every other setup prompt; the only
+// behavioral change is the tail-revealing `masked` getter above.
+function renderPasswordFrame(prompt: Omit, message: string): string {
+ const withGuide = settings.withGuide;
+ const title = `${withGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(prompt.state)} ${message}\n`;
+ const masked = prompt.masked;
+ switch (prompt.state) {
+ case 'error': {
+ const bar = withGuide ? `${styleText('yellow', S_BAR)} ` : '';
+ const end = withGuide ? `${styleText('yellow', S_BAR_END)} ` : '';
+ return `${title.trim()}\n${bar}${masked}\n${end}${styleText('yellow', prompt.error)}\n`;
+ }
+ case 'submit': {
+ const bar = withGuide ? `${styleText('gray', S_BAR)} ` : '';
+ return `${title}${bar}${masked ? styleText('dim', masked) : ''}`;
+ }
+ case 'cancel': {
+ const bar = withGuide ? `${styleText('gray', S_BAR)} ` : '';
+ const body = masked ? styleText(['strikethrough', 'dim'], masked) : '';
+ return `${title}${bar}${body}${masked && withGuide ? `\n${styleText('gray', S_BAR)}` : ''}`;
+ }
+ default: {
+ const bar = withGuide ? `${styleText('cyan', S_BAR)} ` : '';
+ const end = withGuide ? styleText('cyan', S_BAR_END) : '';
+ return `${title}${bar}${prompt.userInputWithCursor}\n${end}\n`;
+ }
+ }
+}
+
+export interface RevealPasswordOptions {
+ message: string;
+ mask?: string;
+ tail?: number;
+ validate?: PasswordOptions['validate'];
+ signal?: AbortSignal;
+}
+
+/**
+ * Drop-in replacement for clack's `password()` that reveals the last few
+ * characters of the entered value while typing. Resolves to the raw value or the
+ * clack cancel symbol, matching `password()`'s contract.
+ */
+export function revealPassword(options: RevealPasswordOptions): Promise {
+ const prompt = new RevealTailPasswordPrompt({
+ mask: options.mask ?? S_PASSWORD_MASK,
+ tail: options.tail ?? REVEAL_TAIL_COUNT,
+ validate: options.validate,
+ signal: options.signal,
+ render() {
+ return renderPasswordFrame(this, options.message);
+ },
+ });
+ return prompt.prompt() as Promise;
+}
diff --git a/packages/cli/src/scan.ts b/packages/cli/src/scan.ts
index 4f973e57..5961e3f1 100644
--- a/packages/cli/src/scan.ts
+++ b/packages/cli/src/scan.ts
@@ -1,6 +1,6 @@
import type { KtxProgressPort, KtxScanMode, KtxScanReport, KtxScanWarning } from './context/scan/types.js';
import { runLocalScan } from './context/scan/local-scan.js';
-import { loadKtxProject } from './context/project/project.js';
+import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
import { getKtxCliPackageInfo } from './cli-runtime.js';
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
import type { KtxCliIo } from './index.js';
@@ -8,7 +8,8 @@ import { createKtxCliLocalIngestAdapters } from './local-adapters.js';
import { createKtxCliScanConnector } from './local-scan-connectors.js';
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
import { profileMark } from './startup-profile.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { formatErrorDetail, scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:scan');
@@ -322,8 +323,9 @@ export function createCliScanProgress(
export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps: KtxScanDeps = {}): Promise {
const startedAt = performance.now();
+ let project: KtxLocalProject | undefined;
try {
- const project = await loadKtxProject({ projectDir: args.projectDir });
+ project = await loadKtxProject({ projectDir: args.projectDir });
const resolveEmbeddingProvider = deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider;
const resolution = await resolveEmbeddingProvider(project, {
mode: 'ensure',
@@ -397,6 +399,20 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
...(errorDetail ? { errorDetail } : {}),
},
});
+ await reportException({
+ error,
+ context: { source: 'scan run', handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.connectionId,
+ includeLlm: true,
+ includeEmbeddings: true,
+ env: process.env,
+ }),
+ });
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts
index 3cb6c5d2..002ead30 100644
--- a/packages/cli/src/setup-databases.ts
+++ b/packages/cli/src/setup-databases.ts
@@ -73,6 +73,7 @@ export type KtxSetupDatabaseDriver =
export interface KtxSetupDatabasesArgs {
projectDir: string;
inputMode: 'auto' | 'disabled';
+ debug?: boolean;
yes?: boolean;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
@@ -1626,7 +1627,12 @@ function hasServiceAccountsBlock(connection: KtxProjectConnectionConfig | undefi
return 'serviceAccounts' in filters;
}
-function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal): void {
+function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFilterProposal, debug = false): void {
+ if (debug && proposal.parseFailedTemplateIds.length > 0) {
+ io.stderr.write(
+ `[debug] query-history filter picker could not parse ${proposal.parseFailedTemplateIds.length} template(s): ${proposal.parseFailedTemplateIds.join(', ')}\n`,
+ );
+ }
if (proposal.excludedRoles.length === 0) {
if (proposal.skipped?.reason === 'no-llm') {
io.stdout.write('│ Query-history filter picker skipped: no LLM is configured.\n');
@@ -1635,6 +1641,12 @@ function printQueryHistoryFilterProposal(io: KtxCliIo, proposal: QueryHistoryFil
} else if (proposal.skipped?.reason === 'no-in-scope-history') {
io.stdout.write('│ Query-history filter picker found no in-scope service-account exclusions.\n');
}
+ if (proposal.parseFailedTemplateIds.length > 0) {
+ const count = proposal.parseFailedTemplateIds.length;
+ io.stdout.write(
+ `│ Skipped ${count} query template${count === 1 ? '' : 's'} ktx could not parse (run with --debug to list them).\n`,
+ );
+ }
for (const warning of proposal.warnings) {
io.stdout.write(`│ ! ${warning}\n`);
}
@@ -1727,12 +1739,17 @@ async function maybeProposeQueryHistoryFilters(input: {
deps: input.deps,
});
if (!llmRuntime && !input.deps.queryHistoryFilterPicker) {
- printQueryHistoryFilterProposal(input.io, {
- excludedRoles: [],
- consideredRoleCount: 0,
- skipped: { reason: 'no-llm' },
- warnings: [],
- });
+ printQueryHistoryFilterProposal(
+ input.io,
+ {
+ excludedRoles: [],
+ consideredRoleCount: 0,
+ skipped: { reason: 'no-llm' },
+ warnings: [],
+ parseFailedTemplateIds: [],
+ },
+ input.args.debug === true,
+ );
return;
}
@@ -1773,7 +1790,19 @@ async function maybeProposeQueryHistoryFilters(input: {
userServiceAccountsPresent,
});
- printQueryHistoryFilterProposal(input.io, proposal);
+ printQueryHistoryFilterProposal(input.io, proposal, input.args.debug === true);
+ await emitTelemetryEvent({
+ name: 'query_history_filter_completed',
+ projectDir: input.projectDir,
+ io: input.io,
+ fields: {
+ dialect,
+ consideredRoleCount: proposal.consideredRoleCount,
+ excludedRoleCount: proposal.excludedRoles.length,
+ parseFailedCount: proposal.parseFailedTemplateIds.length,
+ outcome: 'ok',
+ },
+ });
if (proposal.skipped?.reason === 'user-block-present') {
input.io.stdout.write('│ Existing query-history service-account filters left unchanged.\n');
return;
diff --git a/packages/cli/src/setup-embeddings.ts b/packages/cli/src/setup-embeddings.ts
index 8f49bcf1..5d02e3e4 100644
--- a/packages/cli/src/setup-embeddings.ts
+++ b/packages/cli/src/setup-embeddings.ts
@@ -222,8 +222,8 @@ async function chooseCredentialRef(
const choice = await prompts.select({
message: `How should KTX find your ${embeddingBackendDisplayName(backend)} embedding API key?`,
options: [
- { value: 'env', label: `Use ${defaultEnv} from the environment` },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
+ { value: 'env', label: `Use ${defaultEnv} from the environment` },
{ value: 'back', label: 'Back' },
],
});
diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts
index 8e8cf30b..e673cb99 100644
--- a/packages/cli/src/setup-models.ts
+++ b/packages/cli/src/setup-models.ts
@@ -470,8 +470,8 @@ async function chooseCredentialRef(
const choice = await prompts.select({
message: `How should KTX find your Anthropic API key?\n\n${ANTHROPIC_CREDENTIAL_PROMPT_CONTEXT}`,
options: [
- { value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
+ { value: 'env', label: 'Use ANTHROPIC_API_KEY from the environment' },
{ value: 'back', label: 'Back' },
],
});
diff --git a/packages/cli/src/setup-prompts.ts b/packages/cli/src/setup-prompts.ts
index 1609bd76..e508d8ff 100644
--- a/packages/cli/src/setup-prompts.ts
+++ b/packages/cli/src/setup-prompts.ts
@@ -9,12 +9,12 @@ import {
log,
multiselect,
note,
- password,
select,
text,
} from '@clack/prompts';
import type { KtxCliIo } from './cli-runtime.js';
import { withMenuOptionsSpacing, withTextInputNavigation } from './prompt-navigation.js';
+import { revealPassword } from './reveal-password-prompt.js';
import { withSetupInterruptConfirmation } from './setup-interrupt.js';
export interface KtxSetupPromptOption {
@@ -189,7 +189,7 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
},
async password(promptOptions) {
const value = await withSetupInterruptConfirmation(() =>
- password({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
+ revealPassword({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
);
return isCancel(value) ? undefined : String(value);
},
diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts
index 0a66c3a7..25552fbf 100644
--- a/packages/cli/src/setup-sources.ts
+++ b/packages/cli/src/setup-sources.ts
@@ -119,11 +119,11 @@ export interface KtxSetupSourcesDeps {
const SOURCE_OPTIONS: Array<{ value: KtxSetupSourceType; label: string }> = [
{ value: 'dbt', label: 'dbt' },
- { value: 'metricflow', label: 'MetricFlow' },
{ value: 'metabase', label: 'Metabase' },
+ { value: 'notion', label: 'Notion' },
+ { value: 'metricflow', label: 'MetricFlow' },
{ value: 'looker', label: 'Looker' },
{ value: 'lookml', label: 'LookML' },
- { value: 'notion', label: 'Notion' },
];
const SOURCE_LABELS = Object.fromEntries(SOURCE_OPTIONS.map((option) => [option.value, option.label])) as Record<
@@ -269,8 +269,8 @@ async function chooseSourceCredentialRef(input: {
message: `How should KTX find your ${input.label}?`,
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
- { value: 'env', label: `Use ${input.envName} from the environment` },
{ value: 'paste', label: 'Paste a key and save it as a local secret file' },
+ { value: 'env', label: `Use ${input.envName} from the environment` },
{ value: 'back', label: 'Back' },
],
});
@@ -307,8 +307,8 @@ async function chooseGitAuthCredentialRef(input: {
message: `${label} repo requires authentication.`,
options: [
...(input.existingRef ? [{ value: 'keep', label: 'Keep existing credential' }] : []),
- { value: 'env', label: 'Use GITHUB_TOKEN from the environment' },
{ value: 'paste', label: 'Paste a token and save it as a local secret file' },
+ { value: 'env', label: 'Use GITHUB_TOKEN from the environment' },
{ value: 'skip', label: 'Skip — try without authentication' },
{ value: 'back', label: 'Back' },
],
@@ -1063,8 +1063,8 @@ async function promptForInteractiveSource(
const selectedLocation = await prompts.select({
message: `${source} source location`,
options: [
- { value: 'path', label: 'Local path' },
{ value: 'git', label: 'Git URL' },
+ { value: 'path', label: 'Local path' },
{ value: 'back', label: 'Back' },
],
});
@@ -1343,8 +1343,8 @@ async function promptForInteractiveSource(
const crawlMode = await prompts.select({
message: 'Which Notion pages should KTX ingest?',
options: [
- { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
{ value: 'all_accessible', label: 'All pages the integration can access' },
+ { value: 'selected_roots', label: 'Specific pages and their subpages (choose them in a picker)' },
{ value: 'back', label: 'Back' },
],
});
@@ -2064,7 +2064,7 @@ export async function runKtxSetupSourcesStep(
const addMore = await prompts.select({
message: `${readyConnectionIds.length} context source${readyConnectionIds.length > 1 ? 's' : ''} configured (${readyConnectionIds.join(', ')}). Add another?`,
options: [
- { value: 'done', label: 'Done — continue to context build' },
+ { value: 'done', label: 'Done adding context sources' },
{ value: 'edit', label: 'Edit an existing context source' },
{ value: 'add', label: 'Add another context source' },
],
diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts
index a991367e..fc45abb3 100644
--- a/packages/cli/src/setup.ts
+++ b/packages/cli/src/setup.ts
@@ -80,6 +80,7 @@ export type KtxSetupArgs =
agentScope?: KtxAgentScope;
skipAgents?: boolean;
inputMode: 'auto' | 'disabled';
+ debug?: boolean;
yes: boolean;
cliVersion: string;
llmBackend?: KtxSetupLlmBackend;
@@ -735,6 +736,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
{
projectDir: projectResult.projectDir,
inputMode: args.inputMode,
+ ...(args.debug !== undefined ? { debug: args.debug } : {}),
yes: args.yes,
cliVersion: args.cliVersion,
runtimeInstallPolicy: setupRuntimeInstallPolicy(args),
diff --git a/packages/cli/src/sl.ts b/packages/cli/src/sl.ts
index f3eeb33e..dcf5e460 100644
--- a/packages/cli/src/sl.ts
+++ b/packages/cli/src/sl.ts
@@ -26,7 +26,8 @@ import {
type KtxManagedPythonInstallPolicy,
} from './managed-python-command.js';
import { profileMark } from './startup-profile.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:sl');
@@ -202,8 +203,9 @@ function ambiguousSourceMessage(sourceName: string, connectionIds: readonly stri
export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: KtxSlDeps = {}): Promise {
const startedAt = performance.now();
let queryForTelemetry: SemanticLayerQueryInput | undefined;
+ let project: KtxLocalProject | undefined;
try {
- const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
+ project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
if (args.command === 'list') {
const sources = await listLocalSlSources(project, { connectionId: args.connectionId });
await printSlSources({
@@ -320,7 +322,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
projectDir: args.projectDir,
});
const queryExecutor = args.execute ? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)() : undefined;
- const result = await compileLocalSlQuery(project as KtxLocalProject, {
+ const result = await compileLocalSlQuery(project, {
connectionId: args.connectionId,
query,
compute,
@@ -351,6 +353,20 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
const _exhaustive: never = args;
throw new Error(`Unsupported sl command: ${JSON.stringify(_exhaustive)}`);
} catch (error) {
+ await reportException({
+ error,
+ context: { source: `sl ${args.command}`, handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.connectionId,
+ includeLlm: args.command === 'query',
+ includeEmbeddings: args.command === 'search' || args.command === 'query',
+ env: process.env,
+ }),
+ });
if (args.command === 'validate') {
const errorClass = scrubErrorClass(error);
await emitTelemetryEvent({
diff --git a/packages/cli/src/sql.ts b/packages/cli/src/sql.ts
index bfae0608..d3eb6a81 100644
--- a/packages/cli/src/sql.ts
+++ b/packages/cli/src/sql.ts
@@ -7,7 +7,8 @@ import { createKtxCliScanConnector } from './local-scan-connectors.js';
import { createManagedDaemonSqlAnalysisPort } from './managed-python-http.js';
import { profileMark } from './startup-profile.js';
import { isDemoConnection } from './telemetry/demo-detect.js';
-import { emitTelemetryEvent } from './telemetry/index.js';
+import { emitTelemetryEvent, reportException } from './telemetry/index.js';
+import { collectTelemetryRedactionSecrets } from './telemetry/redaction-secrets.js';
import { scrubErrorClass } from './telemetry/scrubber.js';
profileMark('module:sql');
@@ -142,8 +143,9 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
const startedAt = performance.now();
let driver = 'unknown';
let demoConnection = false;
+ let project: KtxLocalProject | undefined;
try {
- const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
+ project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
const connection = project.config.connections[args.connectionId];
if (!connection) {
throw new Error(`Connection "${args.connectionId}" is not configured in ktx.yaml`);
@@ -171,7 +173,7 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector;
let connector: KtxScanConnector | null = null;
try {
- connector = await createScanConnector(project as KtxLocalProject, args.connectionId);
+ connector = await createScanConnector(project, args.connectionId);
if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) {
throw new Error(`Connection "${args.connectionId}" does not support read-only SQL execution.`);
}
@@ -218,6 +220,20 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
...(errorClass ? { errorClass } : {}),
},
});
+ await reportException({
+ error,
+ context: { source: 'sql run', handled: true, fatal: false },
+ projectDir: args.projectDir,
+ io,
+ redactionSecrets: await collectTelemetryRedactionSecrets({
+ project,
+ projectDir: args.projectDir,
+ connectionId: args.connectionId,
+ includeLlm: false,
+ includeEmbeddings: false,
+ env: process.env,
+ }),
+ });
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
diff --git a/packages/cli/src/telemetry/emitter.ts b/packages/cli/src/telemetry/emitter.ts
index 3344e00b..12453262 100644
--- a/packages/cli/src/telemetry/emitter.ts
+++ b/packages/cli/src/telemetry/emitter.ts
@@ -16,6 +16,16 @@ type PostHogClient = {
properties: Record;
groups?: Record;
}): void;
+ captureException(
+ error: unknown,
+ distinctId?: string,
+ additionalProperties?: Record,
+ ): void;
+ captureExceptionImmediate(
+ error: unknown,
+ distinctId?: string,
+ additionalProperties?: Record,
+ ): Promise;
shutdown(): Promise | void;
};
@@ -105,6 +115,57 @@ export async function trackTelemetryEvent(input: {
}
}
+function writeDebugExceptionPayload(input: {
+ error: Error;
+ distinctId: string;
+ properties: Record;
+ stderr: TelemetrySink;
+}): void {
+ input.stderr.write(
+ `[telemetry-exception] ${JSON.stringify({
+ distinctId: input.distinctId,
+ message: input.error.message,
+ name: input.error.name,
+ properties: input.properties,
+ })}\n`,
+ );
+}
+
+export async function trackTelemetryException(input: {
+ error: Error;
+ distinctId: string;
+ properties: Record;
+ env?: TelemetryEmitterEnv;
+ stderr: TelemetrySink;
+ projectApiKey?: string;
+ host?: string;
+ immediate?: boolean;
+}): Promise {
+ const env = input.env ?? process.env;
+
+ if (debugEnabled(env)) {
+ writeDebugExceptionPayload(input);
+ return;
+ }
+
+ const projectApiKey = telemetryProjectApiKey(input.projectApiKey);
+ const host = telemetryHost(env, input.host);
+ const client = await getPostHogClient(projectApiKey, host);
+ if (!client) {
+ return;
+ }
+
+ try {
+ if (input.immediate) {
+ await client.captureExceptionImmediate(input.error, input.distinctId, input.properties);
+ return;
+ }
+ client.captureException(input.error, input.distinctId, input.properties);
+ } catch {
+ return;
+ }
+}
+
export async function shutdownTelemetryEmitter(): Promise {
const client = await clientPromise;
if (!client) {
diff --git a/packages/cli/src/telemetry/events.schema.json b/packages/cli/src/telemetry/events.schema.json
index a75f92f1..c6c3d6f8 100644
--- a/packages/cli/src/telemetry/events.schema.json
+++ b/packages/cli/src/telemetry/events.schema.json
@@ -206,6 +206,17 @@
"errorClass",
"durationMs"
]
+ },
+ {
+ "name": "query_history_filter_completed",
+ "description": "Emitted after the setup query-history service-account filter picker runs.",
+ "fields": [
+ "dialect",
+ "consideredRoleCount",
+ "excludedRoleCount",
+ "parseFailedCount",
+ "outcome"
+ ]
}
],
"$defs": {
@@ -1434,6 +1445,77 @@
"durationMs"
],
"additionalProperties": false
+ },
+ "query_history_filter_completed": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "cliVersion": {
+ "type": "string"
+ },
+ "nodeVersion": {
+ "type": "string"
+ },
+ "osPlatform": {
+ "type": "string"
+ },
+ "osRelease": {
+ "type": "string"
+ },
+ "arch": {
+ "type": "string"
+ },
+ "runtime": {
+ "type": "string",
+ "enum": [
+ "node",
+ "daemon-py"
+ ]
+ },
+ "isCi": {
+ "type": "boolean"
+ },
+ "dialect": {
+ "type": "string"
+ },
+ "consideredRoleCount": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "excludedRoleCount": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "parseFailedCount": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "outcome": {
+ "type": "string",
+ "enum": [
+ "ok",
+ "error"
+ ]
+ }
+ },
+ "required": [
+ "cliVersion",
+ "nodeVersion",
+ "osPlatform",
+ "osRelease",
+ "arch",
+ "runtime",
+ "isCi",
+ "dialect",
+ "consideredRoleCount",
+ "excludedRoleCount",
+ "parseFailedCount",
+ "outcome"
+ ],
+ "additionalProperties": false
}
}
}
diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts
index c4fc2e6f..cf650492 100644
--- a/packages/cli/src/telemetry/events.ts
+++ b/packages/cli/src/telemetry/events.ts
@@ -206,6 +206,16 @@ const sqlGenCompletedSchema = telemetryCommonEnvelopeSchema
})
.strict();
+const queryHistoryFilterCompletedSchema = telemetryCommonEnvelopeSchema
+ .extend({
+ dialect: z.string(),
+ consideredRoleCount: z.number().int().nonnegative(),
+ excludedRoleCount: z.number().int().nonnegative(),
+ parseFailedCount: z.number().int().nonnegative(),
+ outcome: outcomeSchema,
+ })
+ .strict();
+
/** @internal */
export const telemetryEventSchemas = {
install_first_run: installFirstRunSchema,
@@ -225,6 +235,7 @@ export const telemetryEventSchemas = {
daemon_stopped: daemonStoppedSchema,
sl_plan_completed: slPlanCompletedSchema,
sql_gen_completed: sqlGenCompletedSchema,
+ query_history_filter_completed: queryHistoryFilterCompletedSchema,
} as const;
/** @internal */
@@ -360,6 +371,11 @@ export const telemetryEventCatalog = [
description: 'Emitted after daemon SQL generation completes.',
fields: ['outcome', 'dialect', 'errorClass', 'durationMs'],
},
+ {
+ name: 'query_history_filter_completed',
+ description: 'Emitted after the setup query-history service-account filter picker runs.',
+ fields: ['dialect', 'consideredRoleCount', 'excludedRoleCount', 'parseFailedCount', 'outcome'],
+ },
] as const;
export type TelemetryEventName = keyof typeof telemetryEventSchemas;
diff --git a/packages/cli/src/telemetry/exception.ts b/packages/cli/src/telemetry/exception.ts
new file mode 100644
index 00000000..0ce81244
--- /dev/null
+++ b/packages/cli/src/telemetry/exception.ts
@@ -0,0 +1,201 @@
+import { inspect } from 'node:util';
+
+import { getKtxCliPackageInfo, type KtxCliIo, type KtxCliPackageInfo } from '../cli-runtime.js';
+import { buildCommonEnvelope } from './events.js';
+import { trackTelemetryException } from './emitter.js';
+import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
+
+export interface ExceptionContext {
+ source: string;
+ handled: boolean;
+ fatal: boolean;
+ extra?: Record;
+}
+
+type AnyObject = object;
+
+const reportedObjects = new WeakSet();
+const recentHandledPrimitives: string[] = [];
+const RECENT_PRIMITIVE_LIMIT = 128;
+
+function primitiveKey(value: unknown): string {
+ return `${typeof value}:${String(value)}`;
+}
+
+function rememberHandledPrimitive(value: unknown): void {
+ recentHandledPrimitives.push(primitiveKey(value));
+ if (recentHandledPrimitives.length > RECENT_PRIMITIVE_LIMIT) {
+ recentHandledPrimitives.splice(0, recentHandledPrimitives.length - RECENT_PRIMITIVE_LIMIT);
+ }
+}
+
+function consumeHandledPrimitive(value: unknown): boolean {
+ const key = primitiveKey(value);
+ const index = recentHandledPrimitives.indexOf(key);
+ if (index < 0) {
+ return false;
+ }
+ recentHandledPrimitives.splice(index, 1);
+ return true;
+}
+
+function shouldSkipAsAlreadyReported(error: unknown, handled: boolean): boolean {
+ if ((typeof error === 'object' || typeof error === 'function') && error !== null) {
+ if (reportedObjects.has(error)) {
+ return true;
+ }
+ reportedObjects.add(error);
+ return false;
+ }
+
+ if (handled) {
+ rememberHandledPrimitive(error);
+ return false;
+ }
+
+ return consumeHandledPrimitive(error);
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function redactStaticPatterns(value: string): string {
+ return value
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi, '$1[redacted]$3')
+ .replace(/\b(password|pwd)=([^;&\s]+)/gi, '$1=[redacted]')
+ .replace(/\bAuthorization\s*:\s*[^\r\n,;]+/gi, 'Authorization: [redacted]')
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
+ .replace(/\b(api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
+ .replace(/\b(KTX_[A-Z0-9_]*|[A-Z0-9_]*(?:TOKEN|SECRET))\s*[:=]\s*([^\s,;]+)/g, '$1=[redacted]')
+ .replace(/([?&](?:X-Amz-Signature|X-Goog-Signature|sig)=)[^&\s]+/gi, '$1[redacted]');
+}
+
+function redactText(value: string, secrets: ReadonlyArray): string {
+ let redacted = value;
+ for (const secret of secrets) {
+ if (secret) {
+ redacted = redacted.replace(new RegExp(escapeRegExp(secret), 'g'), '[redacted]');
+ }
+ }
+ return redactStaticPatterns(redacted);
+}
+
+const FORBIDDEN_EXTRA_PROPERTY_KEYS = new Set([
+ 'argv',
+ 'args',
+ 'env',
+ 'environment',
+ 'sql',
+ 'query',
+ 'prompt',
+ 'mcparguments',
+ 'mcpargs',
+ 'tablename',
+ 'schemaname',
+ 'columnname',
+ 'databaseurl',
+ 'connectionstring',
+ 'url',
+ 'password',
+ 'token',
+ 'apikey',
+ 'api_key',
+ 'authorization',
+]);
+
+function safeExtraProperties(
+ extra: Record | undefined,
+): Record {
+ const safe: Record = {};
+ for (const [key, value] of Object.entries(extra ?? {})) {
+ if (!FORBIDDEN_EXTRA_PROPERTY_KEYS.has(key.replace(/[^a-z0-9_]/gi, '').toLowerCase())) {
+ safe[key] = value;
+ }
+ }
+ return safe;
+}
+
+function toMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ if (typeof error === 'string') {
+ return error;
+ }
+ return inspect(error, { depth: 4, breakLength: 120 });
+}
+
+function sanitizedError(error: unknown, secrets: ReadonlyArray): Error {
+ if (error instanceof Error) {
+ const cause = 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined;
+ const clone = new Error(redactText(error.message, secrets), {
+ ...(cause !== undefined ? { cause: sanitizedError(cause, secrets) } : {}),
+ });
+ clone.name = error.name;
+ if (error.stack) {
+ clone.stack = redactText(error.stack, secrets);
+ }
+ return clone;
+ }
+ return new Error(redactText(toMessage(error), secrets));
+}
+
+export async function reportException(input: {
+ error: unknown;
+ context: ExceptionContext;
+ io: KtxCliIo;
+ packageInfo?: KtxCliPackageInfo;
+ projectDir?: string;
+ immediate?: boolean;
+ redactionSecrets?: ReadonlyArray;
+}): Promise {
+ try {
+ if (shouldSkipAsAlreadyReported(input.error, input.context.handled)) {
+ return;
+ }
+
+ const debug = process.env.KTX_TELEMETRY_DEBUG === '1';
+ const identity = await loadTelemetryIdentity({
+ stderr: input.io.stderr,
+ env: process.env,
+ });
+
+ if ((!identity.enabled || !identity.installId) && !debug) {
+ return;
+ }
+
+ const packageInfo = input.packageInfo ?? getKtxCliPackageInfo();
+ const installId = identity.installId ?? 'debug';
+ const projectId = input.projectDir ? computeTelemetryProjectId(installId, input.projectDir) : undefined;
+ const safeError = sanitizedError(input.error, input.redactionSecrets ?? []);
+ const properties: Record = {
+ ...buildCommonEnvelope({
+ cliVersion: packageInfo.version,
+ isCi: Boolean(process.env.CI),
+ }),
+ source: input.context.source,
+ handled: input.context.handled,
+ fatal: input.context.fatal,
+ ...(projectId ? { projectId } : {}),
+ ...safeExtraProperties(input.context.extra),
+ };
+
+ delete properties.$groups;
+ await trackTelemetryException({
+ error: safeError,
+ distinctId: installId,
+ properties,
+ env: process.env,
+ stderr: input.io.stderr,
+ immediate: input.immediate,
+ });
+ } catch {
+ return;
+ }
+}
+
+/** @internal */
+export function __resetTelemetryExceptionStateForTests(): void {
+ recentHandledPrimitives.length = 0;
+}
diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts
index b02e0224..e3716060 100644
--- a/packages/cli/src/telemetry/index.ts
+++ b/packages/cli/src/telemetry/index.ts
@@ -7,6 +7,7 @@ import {
type CompletedCommandSpan,
} from './command-hook.js';
import { shutdownTelemetryEmitter, trackTelemetryEvent } from './emitter.js';
+import { reportException, type ExceptionContext } from './exception.js';
import {
buildCommonEnvelope,
buildTelemetryEvent,
@@ -17,8 +18,8 @@ import {
import { computeTelemetryProjectId, loadTelemetryIdentity } from './identity.js';
import { buildProjectStackSnapshotFields } from './project-snapshot.js';
-export { beginCommandSpan, completeCommandSpan, shutdownTelemetryEmitter };
-export type { CommandOutcome, CompletedCommandSpan };
+export { beginCommandSpan, completeCommandSpan, reportException, shutdownTelemetryEmitter };
+export type { CommandOutcome, CompletedCommandSpan, ExceptionContext };
export async function showTelemetryNoticeIfNeeded(io: KtxCliIo, packageInfo: KtxCliPackageInfo): Promise {
const identity = await loadTelemetryIdentity({
diff --git a/packages/cli/src/telemetry/redaction-secrets.ts b/packages/cli/src/telemetry/redaction-secrets.ts
new file mode 100644
index 00000000..2bf7a863
--- /dev/null
+++ b/packages/cli/src/telemetry/redaction-secrets.ts
@@ -0,0 +1,117 @@
+import { resolveKtxConfigReference } from '../context/core/config-reference.js';
+import { loadKtxProject, type KtxLocalProject } from '../context/project/project.js';
+
+const SENSITIVE_KEY =
+ /(password|secret|token|api[_-]?key|auth[_-]?token|auth_token_ref|private[_-]?key|passphrase|credential|authorization|url)$/i;
+
+type TelemetryRedactionProject = Pick;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function addSecret(values: string[], value: string | undefined): void {
+ const trimmed = value?.trim();
+ if (trimmed && !values.includes(trimmed)) {
+ values.push(trimmed);
+ }
+}
+
+function tryResolve(value: string, env: NodeJS.ProcessEnv): string | undefined {
+ try {
+ return resolveKtxConfigReference(value, env);
+ } catch {
+ return undefined;
+ }
+}
+
+function addUrlCredentials(values: string[], value: string): void {
+ try {
+ const parsed = new URL(value);
+ addSecret(values, parsed.password ? decodeURIComponent(parsed.password) : undefined);
+ addSecret(values, parsed.username ? decodeURIComponent(parsed.username) : undefined);
+ } catch {
+ return;
+ }
+}
+
+function collectFromRecord(input: unknown, env: NodeJS.ProcessEnv, values: string[]): void {
+ if (Array.isArray(input)) {
+ for (const item of input) {
+ collectFromRecord(item, env, values);
+ }
+ return;
+ }
+
+ if (!isRecord(input)) {
+ return;
+ }
+
+ for (const [key, raw] of Object.entries(input)) {
+ if (isRecord(raw) || Array.isArray(raw)) {
+ collectFromRecord(raw, env, values);
+ continue;
+ }
+ if (typeof raw !== 'string' || !SENSITIVE_KEY.test(key)) {
+ continue;
+ }
+ const resolved = tryResolve(raw, env);
+ addSecret(values, resolved);
+ if (resolved) {
+ addUrlCredentials(values, resolved);
+ }
+ }
+}
+
+function collectLlmSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
+ collectFromRecord(project.config.llm.provider, env, values);
+}
+
+function collectEmbeddingSecrets(project: TelemetryRedactionProject, env: NodeJS.ProcessEnv, values: string[]): void {
+ collectFromRecord(project.config.ingest.embeddings, env, values);
+ collectFromRecord(project.config.scan.enrichment.embeddings, env, values);
+}
+
+function collectConnectionSecrets(
+ project: TelemetryRedactionProject,
+ connectionId: string | undefined,
+ env: NodeJS.ProcessEnv,
+ values: string[],
+): void {
+ if (!connectionId) {
+ return;
+ }
+ collectFromRecord(project.config.connections[connectionId], env, values);
+}
+
+export async function collectTelemetryRedactionSecrets(input: {
+ project?: TelemetryRedactionProject;
+ projectDir?: string;
+ connectionId?: string;
+ includeLlm?: boolean;
+ includeEmbeddings?: boolean;
+ env?: NodeJS.ProcessEnv;
+}): Promise {
+ const env = input.env ?? process.env;
+ let project = input.project;
+ if (!project && input.projectDir) {
+ try {
+ project = await loadKtxProject({ projectDir: input.projectDir });
+ } catch {
+ project = undefined;
+ }
+ }
+ if (!project) {
+ return [];
+ }
+
+ const values: string[] = [];
+ if (input.includeLlm) {
+ collectLlmSecrets(project, env, values);
+ }
+ if (input.includeEmbeddings) {
+ collectEmbeddingSecrets(project, env, values);
+ }
+ collectConnectionSecrets(project, input.connectionId, env, values);
+ return values;
+}
diff --git a/packages/cli/src/update-check/cache.ts b/packages/cli/src/update-check/cache.ts
new file mode 100644
index 00000000..19ebf07a
--- /dev/null
+++ b/packages/cli/src/update-check/cache.ts
@@ -0,0 +1,45 @@
+import { renameSync, writeFileSync } from 'node:fs';
+import { mkdir, readFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { z } from 'zod';
+
+const updateCheckCacheSchema = z
+ .object({
+ checkedAt: z.string(),
+ channel: z.enum(['latest', 'next']),
+ installedVersion: z.string(),
+ latestForChannel: z.string(),
+ lastNoticeAt: z.string().optional(),
+ })
+ .strict();
+
+export type UpdateCheckCache = z.infer;
+
+/** @internal */
+export function updateCheckCachePath(homeDir = homedir()): string {
+ return join(homeDir, '.ktx', 'update-check.json');
+}
+
+export async function readUpdateCheckCache(options: { homeDir?: string } = {}): Promise {
+ try {
+ return updateCheckCacheSchema.parse(JSON.parse(await readFile(updateCheckCachePath(options.homeDir), 'utf-8')));
+ } catch {
+ return null;
+ }
+}
+
+export async function writeUpdateCheckCache(
+ value: UpdateCheckCache,
+ options: { homeDir?: string } = {},
+): Promise {
+ try {
+ const path = updateCheckCachePath(options.homeDir);
+ await mkdir(dirname(path), { recursive: true });
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
+ renameSync(tempPath, path);
+ } catch {
+ return;
+ }
+}
diff --git a/packages/cli/src/update-check/channel.ts b/packages/cli/src/update-check/channel.ts
new file mode 100644
index 00000000..d8251021
--- /dev/null
+++ b/packages/cli/src/update-check/channel.ts
@@ -0,0 +1,43 @@
+import semver from 'semver';
+
+export type UpdateChannel = 'latest' | 'next';
+
+export type UpdateDecision =
+ | { status: 'skip' }
+ | { status: 'upToDate'; channel: UpdateChannel; target: string }
+ | { status: 'available'; channel: UpdateChannel; target: string };
+
+/** @internal */
+export function inferUpdateChannel(installed: string): UpdateChannel | null {
+ const parsed = semver.parse(installed);
+ if (!parsed || installed === '0.0.0') {
+ return null;
+ }
+
+ const [prereleaseId] = parsed.prerelease;
+ if (prereleaseId === undefined) {
+ return 'latest';
+ }
+ if (prereleaseId === 'rc') {
+ return 'next';
+ }
+ return null;
+}
+
+export function decideUpdate(installed: string, distTags: Record): UpdateDecision {
+ const channel = inferUpdateChannel(installed);
+ if (!channel || !semver.valid(installed)) {
+ return { status: 'skip' };
+ }
+
+ const target = distTags[channel];
+ if (!target || !semver.valid(target)) {
+ return { status: 'skip' };
+ }
+
+ if (semver.gt(target, installed)) {
+ return { status: 'available', channel, target };
+ }
+
+ return { status: 'upToDate', channel, target };
+}
diff --git a/packages/cli/src/update-check/registry.ts b/packages/cli/src/update-check/registry.ts
new file mode 100644
index 00000000..f0934933
--- /dev/null
+++ b/packages/cli/src/update-check/registry.ts
@@ -0,0 +1,52 @@
+import { request as httpsRequest } from 'node:https';
+import { URL } from 'node:url';
+import { z } from 'zod';
+
+const DIST_TAGS_URL = new URL('https://registry.npmjs.org/-/package/@kaelio/ktx/dist-tags');
+const distTagsSchema = z.record(z.string(), z.string());
+
+function parseDistTags(raw: string): Record {
+ return distTagsSchema.parse(JSON.parse(raw));
+}
+
+export function fetchDistTags(): Promise> {
+ return new Promise((resolve, reject) => {
+ const request = httpsRequest(
+ DIST_TAGS_URL,
+ {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ },
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+ response.on('data', (chunk: Buffer | string) => {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ });
+ response.on('end', () => {
+ const text = Buffer.concat(chunks).toString('utf8');
+ const statusCode = response.statusCode ?? 0;
+ if (statusCode < 200 || statusCode >= 300) {
+ reject(new Error(`npm dist-tags request failed with ${statusCode}: ${text}`));
+ return;
+ }
+ try {
+ resolve(parseDistTags(text));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ },
+ );
+
+ request.on('socket', (socket) => {
+ socket.unref();
+ });
+ request.on('error', reject);
+ request.setTimeout(5000, () => {
+ request.destroy(new Error('npm dist-tags request timed out'));
+ });
+ request.end();
+ });
+}
diff --git a/packages/cli/src/update-check/update-check.ts b/packages/cli/src/update-check/update-check.ts
new file mode 100644
index 00000000..611a43a3
--- /dev/null
+++ b/packages/cli/src/update-check/update-check.ts
@@ -0,0 +1,187 @@
+import type { KtxCliIo } from '../cli-runtime.js';
+import { cyan, dim, type CliStyleEnv } from '../clack.js';
+import { resolveOutputMode } from '../io/mode.js';
+import { type UpdateCheckCache, readUpdateCheckCache, writeUpdateCheckCache } from './cache.js';
+import { decideUpdate, inferUpdateChannel, type UpdateChannel } from './channel.js';
+import { fetchDistTags as defaultFetchDistTags } from './registry.js';
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** @internal */
+export interface UpdateCheckEnv extends NodeJS.ProcessEnv, CliStyleEnv {
+ CI?: string;
+ DO_NOT_TRACK?: string;
+ KTX_NO_UPDATE_CHECK?: string;
+ KTX_OUTPUT?: string;
+ NO_UPDATE_NOTIFIER?: string;
+}
+
+/** @internal */
+export interface UpdateCheckCommandOptions {
+ format?: unknown;
+ json?: unknown;
+ output?: unknown;
+}
+
+export interface PrepareUpdateCheckNoticeOptions {
+ commandOptions?: UpdateCheckCommandOptions;
+ env?: UpdateCheckEnv;
+ fetchDistTags?: () => Promise>;
+ homeDir?: string;
+ installedVersion: string;
+ io: KtxCliIo;
+ now?: () => Date;
+}
+
+export interface PreparedUpdateCheckNotice {
+ notice: string | null;
+}
+
+function truthy(value: string | undefined): boolean {
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
+}
+
+function commandRequestsJson(options: UpdateCheckCommandOptions | undefined): boolean {
+ return options?.json === true || options?.output === 'json' || options?.format === 'json';
+}
+
+/** @internal */
+export function shouldSuppressUpdateCheck(args: {
+ commandOptions?: UpdateCheckCommandOptions;
+ env?: UpdateCheckEnv;
+ io: KtxCliIo;
+}): boolean {
+ const env = args.env ?? process.env;
+ if (truthy(env.KTX_NO_UPDATE_CHECK) || truthy(env.NO_UPDATE_NOTIFIER) || truthy(env.DO_NOT_TRACK)) {
+ return true;
+ }
+
+ if (commandRequestsJson(args.commandOptions) || truthy(env.CI) || args.io.stdout.isTTY !== true) {
+ return true;
+ }
+
+ try {
+ const mode = resolveOutputMode({
+ json: false,
+ io: args.io,
+ env,
+ });
+ return mode !== 'pretty';
+ } catch {
+ return true;
+ }
+}
+
+/** @internal */
+export function renderUpdateNotice(args: {
+ channel: UpdateChannel;
+ env?: CliStyleEnv;
+ installedVersion: string;
+ targetVersion: string;
+}): string {
+ const command = args.channel === 'next' ? 'npm i -g @kaelio/ktx@next' : 'npm i -g @kaelio/ktx';
+ return `${cyan('↑', args.env)} Update available: ktx ${args.installedVersion} → ${args.targetVersion}\n ${dim(command, args.env)}\n`;
+}
+
+function timestampMs(value: string | undefined): number | null {
+ if (!value) {
+ return null;
+ }
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+function elapsedAtLeast(value: string | undefined, now: Date, intervalMs: number): boolean {
+ const previous = timestampMs(value);
+ if (previous === null) {
+ return true;
+ }
+ return now.getTime() - previous >= intervalMs;
+}
+
+function shouldRefreshCache(cache: UpdateCheckCache | null, installedVersion: string, now: Date): boolean {
+ if (!cache || cache.installedVersion !== installedVersion) {
+ return true;
+ }
+ return elapsedAtLeast(cache.checkedAt, now, DAY_MS);
+}
+
+async function refreshUpdateCache(args: {
+ cache: UpdateCheckCache | null;
+ fetchDistTags: () => Promise>;
+ homeDir?: string;
+ installedVersion: string;
+ now: Date;
+}): Promise {
+ const distTags = await args.fetchDistTags();
+ const decision = decideUpdate(args.installedVersion, distTags);
+ if (decision.status === 'skip') {
+ return;
+ }
+
+ await writeUpdateCheckCache(
+ {
+ checkedAt: args.now.toISOString(),
+ channel: decision.channel,
+ installedVersion: args.installedVersion,
+ latestForChannel: decision.target,
+ ...(args.cache?.installedVersion === args.installedVersion && args.cache.channel === decision.channel
+ ? { lastNoticeAt: args.cache.lastNoticeAt }
+ : {}),
+ },
+ { homeDir: args.homeDir },
+ );
+}
+
+export async function prepareUpdateCheckNotice(
+ options: PrepareUpdateCheckNoticeOptions,
+): Promise {
+ const env = options.env ?? process.env;
+ const now = (options.now ?? (() => new Date()))();
+ const fetchDistTags = options.fetchDistTags ?? defaultFetchDistTags;
+
+ if (
+ shouldSuppressUpdateCheck({
+ commandOptions: options.commandOptions,
+ env,
+ io: options.io,
+ })
+ ) {
+ return { notice: null };
+ }
+
+ if (!inferUpdateChannel(options.installedVersion)) {
+ return { notice: null };
+ }
+
+ let cache = await readUpdateCheckCache({ homeDir: options.homeDir });
+ let notice: string | null = null;
+
+ if (cache?.installedVersion === options.installedVersion) {
+ const decision = decideUpdate(options.installedVersion, {
+ [cache.channel]: cache.latestForChannel,
+ });
+ if (decision.status === 'available' && elapsedAtLeast(cache.lastNoticeAt, now, DAY_MS)) {
+ notice = renderUpdateNotice({
+ channel: decision.channel,
+ env,
+ installedVersion: options.installedVersion,
+ targetVersion: decision.target,
+ });
+ cache = { ...cache, lastNoticeAt: now.toISOString() };
+ await writeUpdateCheckCache(cache, { homeDir: options.homeDir });
+ }
+ }
+
+ if (shouldRefreshCache(cache, options.installedVersion, now)) {
+ void refreshUpdateCache({
+ cache,
+ fetchDistTags,
+ homeDir: options.homeDir,
+ installedVersion: options.installedVersion,
+ now,
+ }).catch(() => {});
+ }
+
+ return { notice };
+}
diff --git a/packages/cli/test/cli-program-telemetry.test.ts b/packages/cli/test/cli-program-telemetry.test.ts
index 4e7130b3..30e2bd2b 100644
--- a/packages/cli/test/cli-program-telemetry.test.ts
+++ b/packages/cli/test/cli-program-telemetry.test.ts
@@ -7,6 +7,12 @@ import { runCommanderKtxCli } from '../src/cli-program.js';
import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../src/cli-runtime.js';
import { TELEMETRY_NOTICE } from '../src/telemetry/identity.js';
+const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
+
+vi.mock('../src/telemetry/exception.js', () => ({
+ reportException: reportExceptionMock,
+}));
+
function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } {
let stdout = '';
let stderr = '';
@@ -43,6 +49,7 @@ describe('runCommanderKtxCli telemetry', () => {
vi.stubEnv('CI', '');
vi.stubEnv('KTX_TELEMETRY_DISABLED', '');
vi.stubEnv('DO_NOT_TRACK', '');
+ reportExceptionMock.mockClear();
});
afterEach(async () => {
@@ -131,4 +138,30 @@ describe('runCommanderKtxCli telemetry', () => {
await expect(runCommanderKtxCli(['unknown'], unknownIo.io, {}, info, { runInit: async () => 0 })).resolves.toBe(1);
expect(unknownIo.stderr()).not.toContain('[telemetry]');
});
+
+ it('reports genuine top-level command catches as handled exceptions', async () => {
+ const io = makeIo(true);
+ const deps: KtxCliDeps = {
+ doctor: async () => {
+ throw new Error('status failed');
+ },
+ };
+
+ await expect(
+ runCommanderKtxCli(
+ ['--project-dir', tempDir, 'status', '--json'],
+ io.io,
+ deps,
+ info,
+ { runInit: async () => 0 },
+ ),
+ ).resolves.toBe(1);
+
+ expect(reportExceptionMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ context: expect.objectContaining({ source: 'ktx status', handled: true, fatal: false }),
+ projectDir: tempDir,
+ }),
+ );
+ });
});
diff --git a/packages/cli/test/connection.test.ts b/packages/cli/test/connection.test.ts
index 67e55af8..22c8bbe9 100644
--- a/packages/cli/test/connection.test.ts
+++ b/packages/cli/test/connection.test.ts
@@ -10,6 +10,12 @@ import type { KtxConnectionDriver, KtxScanConnector } from '../src/context/scan/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxConnection } from '../src/connection.js';
+const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
+
+vi.mock('../src/telemetry/exception.js', () => ({
+ reportException: reportExceptionMock,
+}));
+
function stripAnsi(s: string): string {
return s.replace(/\[[0-9;]*m/g, '');
}
@@ -38,7 +44,7 @@ function makeIo() {
function nativeConnector(
driver: KtxConnectionDriver,
- testResult: { success: true } | { success: false; error: string } = { success: true },
+ testResult: { success: true } | { success: false; error: string; cause?: unknown } = { success: true },
) {
const testConnection = vi.fn(async () => testResult);
const cleanup = vi.fn(async () => undefined);
@@ -72,6 +78,7 @@ describe('runKtxConnection', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-connection-'));
+ reportExceptionMock.mockClear();
});
afterEach(async () => {
@@ -165,12 +172,13 @@ describe('runKtxConnection', () => {
it('records the raw errorDetail in connection_test telemetry when a native test fails', async () => {
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
vi.stubEnv('CI', '');
+ vi.stubEnv('DATABASE_URL', 'postgres://svc:db-url-password@db.example.test/analytics'); // pragma: allowlist secret
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
- warehouse: { driver: 'sqlite' },
+ warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
});
- const { connector } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' });
+ const { connector } = nativeConnector('postgres', { success: false, error: 'database file is unreadable' });
const io = makeIo();
const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
@@ -181,6 +189,44 @@ describe('runKtxConnection', () => {
expect(io.stderr()).toContain('"event":"connection_test"');
expect(io.stderr()).toContain('"outcome":"error"');
expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"');
+ expect(reportExceptionMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ context: expect.objectContaining({ source: 'connection test', handled: true, fatal: false }),
+ projectDir,
+ redactionSecrets: expect.arrayContaining([
+ 'postgres://svc:db-url-password@db.example.test/analytics', // pragma: allowlist secret
+ 'db-url-password',
+ ]),
+ }),
+ );
+ });
+
+ it('preserves the driver error class and code in connection_test telemetry', async () => {
+ vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
+ vi.stubEnv('CI', '');
+ const projectDir = join(tempDir, 'project');
+ await initKtxProject({ projectDir });
+ await writeConnections(projectDir, {
+ warehouse: { driver: 'sqlserver', host: 'db.example.test', database: 'analytics', username: 'svc_ro' },
+ });
+ class ConnectionError extends Error {
+ readonly code = 'ELOGIN';
+ }
+ const driverError = new ConnectionError("Login failed for user 'svc_ro'.");
+ const { connector } = nativeConnector('sqlserver', {
+ success: false,
+ error: driverError.message,
+ cause: driverError,
+ });
+ const io = makeIo();
+
+ const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
+ createScanConnector: vi.fn(async () => connector),
+ });
+
+ expect(code).toBe(1);
+ expect(io.stderr()).toContain('"errorClass":"ConnectionError"');
+ expect(io.stderr()).toContain('"errorDetail":"ELOGIN: Login failed for user \'svc_ro\'."');
});
it('reports the connector error and still cleans up when native testConnection fails', async () => {
diff --git a/packages/cli/test/context/core/abort.test.ts b/packages/cli/test/context/core/abort.test.ts
new file mode 100644
index 00000000..aed46c1e
--- /dev/null
+++ b/packages/cli/test/context/core/abort.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createAbortError, isAbortError, linkAbortSignal, throwIfAborted } from '../../../src/context/core/abort.js';
+
+describe('abort helpers', () => {
+ it('recognizes DOMException abort errors and common abort-shaped errors', () => {
+ expect(isAbortError(createAbortError())).toBe(true);
+ expect(isAbortError(Object.assign(new Error('cancelled'), { name: 'AbortError' }))).toBe(true);
+ expect(isAbortError(Object.assign(new Error('operation aborted'), { code: 'ABORT_ERR' }))).toBe(true);
+ expect(isAbortError(new Error('ordinary failure'))).toBe(false);
+ });
+
+ it('throws when the provided signal is already aborted', () => {
+ const controller = new AbortController();
+ controller.abort();
+
+ expect(() => throwIfAborted(controller.signal)).toThrow(/Aborted/);
+ });
+
+ it('links a child controller to a parent signal and removes the listener on dispose', () => {
+ const parent = new AbortController();
+ const child = linkAbortSignal(parent.signal);
+
+ expect(child.controller.signal.aborted).toBe(false);
+ parent.abort();
+ expect(child.controller.signal.aborted).toBe(true);
+
+ const removeSpy = vi.spyOn(parent.signal, 'removeEventListener');
+ child.dispose();
+ expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
+ });
+});
diff --git a/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts
index 4c295092..5c9e2e60 100644
--- a/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts
+++ b/packages/cli/test/context/ingest/adapters/historic-sql/query-history-filter-picker.test.ts
@@ -64,6 +64,27 @@ function sqlAnalysis(tablesById: Record>,
+ errorIds: string[],
+): SqlAnalysisPort {
+ const errors = new Set(errorIds);
+ return {
+ analyzeForFingerprint: vi.fn(),
+ analyzeBatch: vi.fn(async (items: SqlAnalysisBatchItem[]): Promise