mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf1fe9748e | ||
|
|
698efdcef8 | ||
|
|
377f21acd7 | ||
|
|
d3e20df1d5 | ||
|
|
d14227468b | ||
|
|
fb7b94b60e | ||
|
|
c3d8cedb0b | ||
|
|
5a8821073b | ||
|
|
ec7edf8f50 | ||
|
|
c2beaf7d55 | ||
|
|
8eb1cd3e79 | ||
|
|
7ba948a135 | ||
|
|
e70ae1e63b | ||
|
|
ce1516b357 | ||
|
|
f5dea9a089 | ||
|
|
9d3a0b751d | ||
|
|
45aa95d2cc | ||
|
|
cb6a67c2d7 | ||
|
|
2334a4b6e3 | ||
|
|
6da8c3452a | ||
|
|
494618ab14 | ||
|
|
74c6076b72 | ||
|
|
41e20c9ce7 | ||
|
|
13774bfcef | ||
|
|
d01abe6f3c |
222 changed files with 17662 additions and 2180 deletions
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -10,5 +10,5 @@ contact_links:
|
|||
url: https://docs.kaelio.com/ktx/docs/community/support
|
||||
about: Full guide on where to ask what — Slack vs. GitHub Issues vs. docs.
|
||||
- name: Security issues
|
||||
url: https://github.com/Kaelio/ktx-ai-data-agents-context/security/advisories/new
|
||||
url: https://github.com/Kaelio/ktx/security/advisories/new
|
||||
about: Report security vulnerabilities privately via GitHub Security Advisories. Please do not file security issues publicly.
|
||||
|
|
|
|||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -212,12 +212,12 @@ jobs:
|
|||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: Kaelio/ktx-ai-data-agents-context
|
||||
slug: Kaelio/ktx
|
||||
files: ./packages/cli/coverage/lcov.info
|
||||
flags: typescript
|
||||
name: typescript
|
||||
disable_search: true
|
||||
fail_ci_if_error: true
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Warn when Codecov token is missing for TypeScript
|
||||
if: env.CODECOV_TOKEN_CONFIGURED != 'true'
|
||||
|
|
@ -231,12 +231,12 @@ jobs:
|
|||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: Kaelio/ktx-ai-data-agents-context
|
||||
slug: Kaelio/ktx
|
||||
files: ./coverage/python.xml
|
||||
flags: python
|
||||
name: python
|
||||
disable_search: true
|
||||
fail_ci_if_error: true
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Warn when Codecov token is missing for Python
|
||||
if: env.CODECOV_TOKEN_CONFIGURED != 'true'
|
||||
|
|
|
|||
2
.github/workflows/star-history.yml
vendored
2
.github/workflows/star-history.yml
vendored
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
set -euo pipefail
|
||||
# cachebust forces star-history to regenerate instead of serving its
|
||||
# own server-side cache; --location follows the slug-normalizing 301.
|
||||
url="https://api.star-history.com/svg?repos=Kaelio/ktx-ai-data-agents-context&type=Date&cachebust=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
url="https://api.star-history.com/svg?repos=Kaelio/ktx&type=Date&cachebust=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
curl --fail --location --silent --show-error \
|
||||
--retry 3 --retry-delay 5 --max-time 60 \
|
||||
-o assets/star-history.svg.new "$url"
|
||||
|
|
|
|||
84
AGENTS.md
84
AGENTS.md
|
|
@ -159,6 +159,65 @@ and naming asymmetries are bugs in waiting — see
|
|||
[`docs/code-design.md`](docs/code-design.md). Treat the `MUST` / `MUST NOT`
|
||||
rules there with the same weight as the ones in this file.
|
||||
|
||||
## Design Reasoning Defaults
|
||||
|
||||
When proposing a design, an approach, or any non-trivial change, apply these
|
||||
defaults and run the self-check before presenting it. They encode the
|
||||
corrections users most often have to make; reaching these conclusions
|
||||
autonomously — without being asked the leading question — is the bar.
|
||||
|
||||
- **MUST**: Optimize for the best outcome, not for an unstated constraint. Do not
|
||||
silently adopt "smallest change", "least effort", "cheapest", or "least user
|
||||
intervention" as the goal unless the user said so. Default to the most correct,
|
||||
durable solution, and present cost / effort / scope as information for the user
|
||||
to weigh — not as a ceiling you impose on their behalf.
|
||||
- **MUST**: Separate one-time cost from recurring cost before discarding an
|
||||
option. A fixed cost paid once (a setup-time computation, an extra LLM call
|
||||
during setup, a contract change) to make every later run cheaper or more
|
||||
correct is usually worth it. Do not reject it with recurring-cost reasoning;
|
||||
quantify both sides. (Example smell: "don't add an LLM call to a cost-cutting
|
||||
feature" — wrong when the call is one-time and the savings recur.)
|
||||
- **MUST**: Treat a user's example as a representative of a class, not as the
|
||||
spec. Design for the general population the example stands for, then stress-test
|
||||
against deliberately different instances — another warehouse, dialect, stack
|
||||
layout, or input shape — before committing. If a design only works because of an
|
||||
incidental property of the example (e.g. "the noise happened to be in a separate
|
||||
schema *on this demo*"), it is curve-fitting; generalize it or state the
|
||||
assumption explicitly.
|
||||
- **MUST**: Prefer deriving from the system's own state over enumerating cases.
|
||||
Favor an allowlist computed from declared/observed state (config, scanned
|
||||
catalog, query log, the user's own inputs) over a denylist of known-bad
|
||||
specifics (particular tables, schemas, tools, or vendors). A hardcoded or
|
||||
hand-maintained list of external specifics is a smell: it rots and fails on the
|
||||
next stack. The only acceptable static patterns are genuinely universal
|
||||
invariants (e.g. DB-engine system catalogs) and ktx's own self-emitted
|
||||
signatures.
|
||||
- **SHOULD**: Before inventing an abstraction or hand-rolling structural logic,
|
||||
search for what already exists and reuse it — the codebase's canonical
|
||||
representation (a structured ref/key type) instead of a parallel string scheme,
|
||||
and a mandated/available tool (e.g. `sqlglot` for SQL structure; see
|
||||
[SQL and Structured Parsing](#sql-and-structured-parsing)) instead of
|
||||
hand-parsing. Normalize ambiguous input to the canonical form at the boundary;
|
||||
do not carry the ambiguity downstream. This is the single-source-of-truth / DRY
|
||||
item from the Priority Hierarchy applied at design time.
|
||||
|
||||
Before presenting a design, answer these explicitly:
|
||||
|
||||
1. Am I optimizing for a goal the user actually stated, or one I assumed?
|
||||
2. Does this generalize beyond the example in front of me? Name a real case where
|
||||
it would break.
|
||||
3. Am I enumerating known-bad cases when I could derive scope from the system's
|
||||
own declared/observed state?
|
||||
4. Is there an existing canonical representation or mandated tool I should reuse
|
||||
instead of building or parsing my own?
|
||||
5. Am I discarding the better option on a weak or misapplied constraint
|
||||
(one-time vs recurring cost, "more surface area", "more work now")?
|
||||
|
||||
A user question that nudges toward an alternative ("would X help?", "should I
|
||||
always do Y?", "will you hardcode Z?") is a signal that a better option exists.
|
||||
Investigate the implied direction and reason it through *before* defending the
|
||||
original proposal — and prefer to have asked yourself the question first.
|
||||
|
||||
## TypeScript Standards
|
||||
|
||||
- Use Node 22+ and pnpm workspace commands.
|
||||
|
|
@ -278,7 +337,8 @@ use `PascalCase` without the suffix.
|
|||
|
||||
## Telemetry
|
||||
|
||||
**ktx** ships PostHog usage telemetry. When adding commands or events:
|
||||
**ktx** ships PostHog usage telemetry. Catalog telemetry events use strict
|
||||
schemas. When adding commands or events:
|
||||
|
||||
- **MUST NOT**: Add fields that carry user data — file paths, hostnames,
|
||||
environment values, SQL text, schema/table/column names, error messages,
|
||||
|
|
@ -295,6 +355,24 @@ use `PascalCase` without the suffix.
|
|||
of collected data changes. Adding another event with no new field types
|
||||
needs no docs change.
|
||||
|
||||
### Error reports
|
||||
|
||||
**ktx** also sends PostHog Error Tracking `$exception` events when telemetry is
|
||||
enabled. This channel is separate from the strict catalog event schema and is
|
||||
used only for exception diagnostics.
|
||||
|
||||
`$exception` events may include stack frames, error class names, raw error
|
||||
messages, cause chains, `source`, `handled`, `fatal`, runtime version fields,
|
||||
OS/runtime fields, and the hashed `projectId` when known. Stack frames may
|
||||
include local file paths and the local username when those appear in paths.
|
||||
|
||||
`$exception` events must never intentionally include secrets, credentials,
|
||||
database URLs, auth headers, raw argv, raw environment values, SQL text,
|
||||
schema/table/column names as explicit properties, customer row data, user prompt
|
||||
text, or raw MCP arguments. Reporters must redact call-site-provided secret
|
||||
snapshots and common static credential patterns before the SDK serializes the
|
||||
exception.
|
||||
|
||||
## Documentation and Specs
|
||||
|
||||
- Keep public documentation in `README.md`, package READMEs, example READMEs,
|
||||
|
|
@ -381,8 +459,8 @@ rather than silently skipping it.
|
|||
- **MUST**: Disable monospace ligatures on every surface that uses the
|
||||
`var(--font-mono)` family (Geist Mono). Geist Mono fuses `--` into an
|
||||
em-dash glyph that visually eats the adjacent space, so prompts like
|
||||
`npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx` render as
|
||||
`Kaelio/ktx-ai-data-agents-context--skill ktx`.
|
||||
`npx skills add Kaelio/ktx --skill ktx` render as
|
||||
`Kaelio/ktx--skill ktx`.
|
||||
- **MUST**: When adding a new container that renders user-visible monospace
|
||||
text outside `<code>` / `<pre>` (e.g. a styled `<div className="font-mono">`
|
||||
for a copyable prompt), verify the global ligature-off rule in
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ layout, and verification commands, see the
|
|||
## How to contribute
|
||||
|
||||
1. Browse open issues labeled
|
||||
[`good first issue`](https://github.com/Kaelio/ktx-ai-data-agents-context/labels/good%20first%20issue)
|
||||
or [`help wanted`](https://github.com/Kaelio/ktx-ai-data-agents-context/labels/help%20wanted).
|
||||
[`good first issue`](https://github.com/Kaelio/ktx/labels/good%20first%20issue)
|
||||
or [`help wanted`](https://github.com/Kaelio/ktx/labels/help%20wanted).
|
||||
2. Comment on the issue to claim it. A maintainer will confirm scope and
|
||||
assign it to you.
|
||||
3. For changes not covered by an existing issue, open one first so we can
|
||||
|
|
@ -82,7 +82,7 @@ page for the full guide. The short version:
|
|||
- **Feature requests**: use the
|
||||
[Feature request](.github/ISSUE_TEMPLATE/feature_request.yml) template.
|
||||
- **Security**: report privately via
|
||||
[GitHub Security Advisories](https://github.com/Kaelio/ktx-ai-data-agents-context/security/advisories/new),
|
||||
[GitHub Security Advisories](https://github.com/Kaelio/ktx/security/advisories/new),
|
||||
not as a public issue.
|
||||
|
||||
## Code of conduct
|
||||
|
|
|
|||
44
README.md
44
README.md
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@kaelio/ktx"><img src="https://img.shields.io/npm/v/@kaelio/ktx?style=flat-square&color=f97316" alt="npm version" /></a>
|
||||
<a href="https://codecov.io/gh/Kaelio/ktx-ai-data-agents-context"><img src="https://codecov.io/gh/Kaelio/ktx-ai-data-agents-context/graph/badge.svg?branch=main" alt="Codecov" /></a>
|
||||
<a href="https://github.com/Kaelio/ktx-ai-data-agents-context/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://img.shields.io/github/actions/workflow/status/Kaelio/ktx-ai-data-agents-context/ci.yml?branch=main&label=tests&style=flat-square" alt="Tests" /></a>
|
||||
<a href="https://codecov.io/gh/Kaelio/ktx"><img src="https://codecov.io/gh/Kaelio/ktx/graph/badge.svg?branch=main" alt="Codecov" /></a>
|
||||
<a href="https://github.com/Kaelio/ktx/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://img.shields.io/github/actions/workflow/status/Kaelio/ktx/ci.yml?branch=main&label=tests&style=flat-square" alt="Tests" /></a>
|
||||
<a href="https://docs.kaelio.com/ktx/docs/"><img src="https://img.shields.io/badge/docs-ktx-22c55e?style=flat-square" alt="Documentation" /></a>
|
||||
<a href="https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ"><img src="https://img.shields.io/badge/slack-join%20community-4A154B?style=flat-square&logo=slack&logoColor=white" alt="Join the ktx Slack community" /></a>
|
||||
<a href="https://github.com/Kaelio/ktx-ai-data-agents-context/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://www.ycombinator.com/companies?batch=P25"><img src="https://img.shields.io/badge/Y%20Combinator-P25-orange?style=flat-square" alt="Y Combinator P25" /></a>
|
||||
<a href="https://github.com/Kaelio/ktx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://www.ycombinator.com/companies/kaelio"><img src="https://img.shields.io/badge/Y%20Combinator-P25-orange?style=flat-square" alt="Y Combinator P25" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -23,6 +23,10 @@
|
|||
<a href="https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ"><b>Slack</b></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Built and maintained by <a href="https://www.kaelio.com"><b>Kaelio</b></a></sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
**ktx** is a self-improving context layer that teaches agents how to query your
|
||||
|
|
@ -30,8 +34,9 @@ warehouse accurately - from approved metric definitions, joinable columns, and
|
|||
business knowledge it builds and maintains for you.
|
||||
|
||||
> [!NOTE]
|
||||
> Run **ktx** with your own LLM API keys or a **Claude Pro/Max** subscription.
|
||||
> No extra usage billing from **ktx**.
|
||||
> Run **ktx** with your own LLM API keys or a local agent sign-in — a
|
||||
> **Claude Pro/Max** subscription through Claude Code, or your local Codex
|
||||
> authentication. No extra usage billing from **ktx**.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://youtu.be/5V4TuzYVlrA">
|
||||
|
|
@ -130,7 +135,7 @@ Agent integration ready: yes (codex:project)
|
|||
> your project directory:
|
||||
>
|
||||
> ```text
|
||||
> Run npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx and use the ktx skill to install
|
||||
> Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install
|
||||
> and configure ktx in this project.
|
||||
> ```
|
||||
|
||||
|
|
@ -175,8 +180,9 @@ then the current directory. Pass `--project-dir <path>` when scripting.
|
|||
No. **ktx** runs locally. The only data leaving your machine is what you
|
||||
send to the LLM provider you configured.
|
||||
- **Which LLM backends are supported?**
|
||||
Anthropic API, Google Vertex AI, AI Gateway, and the local Claude Code
|
||||
session through the Claude Agent SDK. See
|
||||
Anthropic API, Google Vertex AI, AI Gateway, the local Claude Code session
|
||||
through the Claude Agent SDK, and your local Codex authentication through the
|
||||
Codex SDK. See
|
||||
[LLM configuration](https://docs.kaelio.com/ktx/docs/guides/llm-configuration).
|
||||
- **How is ktx different from a dbt or MetricFlow semantic layer?**
|
||||
**ktx** *ingests* those layers and combines them with raw-table
|
||||
|
|
@ -201,7 +207,7 @@ then the current directory. Pass `--project-dir <path>` when scripting.
|
|||
## Community
|
||||
|
||||
- **[Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ)** — ask questions, share what you're building, and chat with maintainers.
|
||||
- **[GitHub Issues](https://github.com/Kaelio/ktx-ai-data-agents-context/issues)** — report bugs and request features.
|
||||
- **[GitHub Issues](https://github.com/Kaelio/ktx/issues)** — report bugs and request features.
|
||||
- **[Contributing](https://docs.kaelio.com/ktx/docs/community/contributing)** — set up the repo, run tests, and open a PR.
|
||||
|
||||
## Development
|
||||
|
|
@ -245,11 +251,17 @@ uv run pytest -q
|
|||
|
||||
## Telemetry
|
||||
|
||||
**ktx** collects anonymous usage telemetry from interactive CLI runs to
|
||||
improve setup, command reliability, and data-agent workflows. No file paths,
|
||||
hostnames, SQL, schema names, error messages, or argv are recorded. See
|
||||
[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the
|
||||
event catalog and opt-out options.
|
||||
**ktx** collects privacy-conscious usage telemetry to understand installs and
|
||||
improve setup, command reliability, and data-agent workflows. Catalog telemetry
|
||||
events do not record file paths, hostnames, SQL, schema names, table names,
|
||||
column names, error messages, raw environment values, or argv. Error reports use
|
||||
PostHog Error Tracking and can include stack frames and raw error messages,
|
||||
which may contain local file paths or the local username in those paths.
|
||||
**ktx** redacts secrets, credentials, database URLs, auth headers, argv, raw
|
||||
environment values, SQL text, row data, and user-typed prompt or MCP argument
|
||||
text from the explicit `$exception` payload. See
|
||||
[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the event
|
||||
catalog and opt-out options.
|
||||
|
||||
## License
|
||||
|
||||
|
|
@ -258,7 +270,7 @@ event catalog and opt-out options.
|
|||
## Star History
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#Kaelio/ktx-ai-data-agents-context&Date">
|
||||
<a href="https://star-history.com/#Kaelio/ktx&Date">
|
||||
<img src="assets/star-history.svg" alt="ktx Star History Chart" width="700" />
|
||||
</a>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
If you believe you've found a security vulnerability in KTX, please report it
|
||||
**privately** through GitHub Security Advisories:
|
||||
|
||||
[Report a vulnerability](https://github.com/Kaelio/ktx-ai-data-agents-context/security/advisories/new)
|
||||
[Report a vulnerability](https://github.com/Kaelio/ktx/security/advisories/new)
|
||||
|
||||
If you cannot use GitHub Security Advisories, email `support@kaelio.com`
|
||||
instead. Please do **not** open a public issue, post in the KTX Slack, or
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
|
@ -5,7 +5,7 @@ import { SlackIcon } from "@/components/slack-icon";
|
|||
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: <Logo />,
|
||||
title: Logo,
|
||||
transparentMode: "top",
|
||||
},
|
||||
links: [
|
||||
|
|
|
|||
|
|
@ -305,8 +305,8 @@ export const runtimeEdges: Edge[] = [
|
|||
sourceHandle: "to-context",
|
||||
target: "context",
|
||||
targetHandle: "in",
|
||||
type: "default",
|
||||
label: "search",
|
||||
type: "smoothstep",
|
||||
label: "search + read",
|
||||
...labelBg,
|
||||
style: edgeStyle,
|
||||
markerStart: marker,
|
||||
|
|
@ -318,7 +318,7 @@ export const runtimeEdges: Edge[] = [
|
|||
sourceHandle: "to-warehouse",
|
||||
target: "warehouse",
|
||||
targetHandle: "in",
|
||||
type: "default",
|
||||
type: "smoothstep",
|
||||
label: "read-only",
|
||||
...labelBg,
|
||||
style: edgeStyle,
|
||||
|
|
|
|||
|
|
@ -1,40 +1,56 @@
|
|||
export function Logo() {
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
const brandFont = {
|
||||
fontFamily: "var(--font-display), var(--font-sans), sans-serif",
|
||||
} as const;
|
||||
|
||||
export function Logo({ href = "/", className }: { href?: string; className?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3.5 group">
|
||||
<div className="relative flex items-center justify-center transition-transform duration-300 ease-out group-hover:rotate-[-4deg]">
|
||||
<img
|
||||
src="/ktx/brand/ktx-mascot.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-20 w-20 object-contain block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/ktx/brand/ktx-mascot-dark.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-20 w-20 object-contain hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start leading-none">
|
||||
<div className={className}>
|
||||
<div className="flex items-center gap-3.5 group">
|
||||
<Link href={href} aria-label="ktx documentation home" className="flex items-center no-underline">
|
||||
<span className="relative flex items-center justify-center transition-transform duration-300 ease-out group-hover:rotate-[-4deg]">
|
||||
<img
|
||||
src="/ktx/brand/ktx-mascot.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-20 w-20 object-contain block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/ktx/brand/ktx-mascot-dark.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-20 w-20 object-contain hidden dark:block"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex flex-col items-start leading-none">
|
||||
<Link
|
||||
href={href}
|
||||
className="text-[42px] font-semibold text-fd-foreground tracking-tight no-underline"
|
||||
style={brandFont}
|
||||
>
|
||||
ktx
|
||||
</Link>
|
||||
<a
|
||||
href="https://www.kaelio.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1 whitespace-nowrap text-[13px] font-medium text-fd-muted-foreground/80 tracking-tight no-underline transition-colors hover:text-fd-foreground"
|
||||
style={brandFont}
|
||||
>
|
||||
by Kaelio
|
||||
</a>
|
||||
</div>
|
||||
<span
|
||||
className="text-[42px] font-semibold text-fd-foreground tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }}
|
||||
className="text-[19px] font-medium text-fd-muted-foreground/80 tracking-tight border-l border-fd-border pl-3 ml-1"
|
||||
style={brandFont}
|
||||
>
|
||||
ktx
|
||||
</span>
|
||||
<span
|
||||
className="mt-1 whitespace-nowrap text-[13px] font-medium text-fd-muted-foreground/80 tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }}
|
||||
>
|
||||
by Kaelio
|
||||
Docs
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[19px] font-medium text-fd-muted-foreground/80 tracking-tight border-l border-fd-border pl-3 ml-1"
|
||||
style={{ fontFamily: "var(--font-display), var(--font-sans), sans-serif" }}
|
||||
>
|
||||
Docs
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
576
docs-site/components/product-runtime.tsx
Normal file
576
docs-site/components/product-runtime.tsx
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
type Edge,
|
||||
type EdgeProps,
|
||||
getSmoothStepPath,
|
||||
Handle,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
|
||||
import { FlowCanvas } from "./flow-canvas";
|
||||
|
||||
type AgentNodeData = {
|
||||
title: string;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
type HubNodeData = {
|
||||
title: string;
|
||||
badge: string;
|
||||
rows: string[];
|
||||
};
|
||||
|
||||
type TargetNodeData = {
|
||||
accent: string;
|
||||
title: string;
|
||||
body: string;
|
||||
rows: { text: string; color?: string; mono?: boolean }[];
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
type AgentNode = Node<AgentNodeData, "agent">;
|
||||
type HubNode = Node<HubNodeData, "hub">;
|
||||
type TargetNode = Node<TargetNodeData, "target">;
|
||||
type FlowNode = AgentNode | HubNode | TargetNode;
|
||||
|
||||
const AGENT_W = 252;
|
||||
const AGENT_H = 96;
|
||||
const HUB_W = 306;
|
||||
const HUB_H = 190;
|
||||
const TARGET_W = 268;
|
||||
const TARGET_H = 148;
|
||||
|
||||
const CENTER_X = 470;
|
||||
const ROW_AGENT_Y = 0;
|
||||
const ROW_HUB_Y = 196;
|
||||
const ROW_TARGET_Y = 488;
|
||||
|
||||
const AGENT_X = CENTER_X - AGENT_W / 2;
|
||||
const HUB_X = CENTER_X - HUB_W / 2;
|
||||
|
||||
const TARGET_GAP_X = 38;
|
||||
const TARGETS_TOTAL = TARGET_W * 2 + TARGET_GAP_X;
|
||||
const TARGETS_START_X = CENTER_X - TARGETS_TOTAL / 2;
|
||||
const CONTEXT_X = TARGETS_START_X;
|
||||
const WAREHOUSE_X = TARGETS_START_X + TARGET_W + TARGET_GAP_X;
|
||||
|
||||
const EDGE_STROKE = "#94a3b8";
|
||||
const CYCLE_STROKE = "#0e7490";
|
||||
const EMERALD = "#059669";
|
||||
const TEAL = "#0e7490";
|
||||
|
||||
const nodes: FlowNode[] = [
|
||||
{
|
||||
id: "agent",
|
||||
type: "agent",
|
||||
position: { x: AGENT_X, y: ROW_AGENT_Y },
|
||||
data: {
|
||||
title: "Your agent",
|
||||
items: ["Claude Code", "Cursor", "Codex"],
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
},
|
||||
{
|
||||
id: "hub",
|
||||
type: "hub",
|
||||
position: { x: HUB_X, y: ROW_HUB_Y },
|
||||
data: {
|
||||
title: "ktx",
|
||||
badge: "MCP + CLI",
|
||||
rows: [
|
||||
"Search wiki + semantic layer",
|
||||
"Return approved metrics",
|
||||
"Compile metrics → SQL",
|
||||
],
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
},
|
||||
{
|
||||
id: "context",
|
||||
type: "target",
|
||||
position: { x: CONTEXT_X, y: ROW_TARGET_Y },
|
||||
data: {
|
||||
accent: TEAL,
|
||||
title: "Context layer",
|
||||
body: "Approved definitions agents search before they answer.",
|
||||
rows: [
|
||||
{ text: "wiki/*.md", color: EMERALD, mono: true },
|
||||
{ text: "semantic-layer/*.yaml", color: TEAL, mono: true },
|
||||
],
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
},
|
||||
{
|
||||
id: "warehouse",
|
||||
type: "target",
|
||||
position: { x: WAREHOUSE_X, y: ROW_TARGET_Y },
|
||||
data: {
|
||||
accent: "#334155",
|
||||
title: "Database",
|
||||
badge: "read-only",
|
||||
body: "Runs the compiled SQL. ktx never writes to it.",
|
||||
rows: [],
|
||||
},
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const labelBg = {
|
||||
labelBgPadding: [6, 3] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
labelStyle: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
fill: "var(--color-fd-muted-foreground)",
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: "var(--color-fd-background)",
|
||||
stroke: "var(--color-fd-border)",
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const requestMarker = {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: EDGE_STROKE,
|
||||
width: 16,
|
||||
height: 16,
|
||||
};
|
||||
|
||||
const flowEdges: Edge[] = [
|
||||
{
|
||||
id: "e-ask",
|
||||
source: "agent",
|
||||
sourceHandle: "ask",
|
||||
target: "hub",
|
||||
targetHandle: "ask",
|
||||
type: "straight",
|
||||
label: "ask",
|
||||
...labelBg,
|
||||
style: { stroke: EDGE_STROKE, strokeWidth: 1.5 },
|
||||
markerEnd: requestMarker,
|
||||
},
|
||||
{
|
||||
id: "e-answer",
|
||||
source: "hub",
|
||||
sourceHandle: "answer",
|
||||
target: "agent",
|
||||
targetHandle: "answer",
|
||||
type: "straight",
|
||||
label: "answer",
|
||||
...labelBg,
|
||||
style: { stroke: EDGE_STROKE, strokeWidth: 1.5 },
|
||||
markerEnd: requestMarker,
|
||||
},
|
||||
{
|
||||
id: "e-search",
|
||||
source: "hub",
|
||||
sourceHandle: "to-context",
|
||||
target: "context",
|
||||
targetHandle: "in",
|
||||
type: "smoothstep",
|
||||
label: "search + read",
|
||||
...labelBg,
|
||||
style: { stroke: CYCLE_STROKE, strokeWidth: 1.5 },
|
||||
markerStart: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 },
|
||||
},
|
||||
{
|
||||
id: "e-readonly",
|
||||
source: "hub",
|
||||
sourceHandle: "to-warehouse",
|
||||
target: "warehouse",
|
||||
targetHandle: "in",
|
||||
type: "smoothstep",
|
||||
label: "read-only",
|
||||
...labelBg,
|
||||
style: { stroke: CYCLE_STROKE, strokeWidth: 1.5 },
|
||||
markerStart: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: CYCLE_STROKE, width: 14, height: 14 },
|
||||
},
|
||||
];
|
||||
|
||||
function AgentNodeView({ data }: NodeProps<AgentNode>) {
|
||||
return (
|
||||
<div
|
||||
style={{ width: AGENT_W, height: AGENT_H }}
|
||||
className="flex flex-col justify-center rounded-md border border-fd-border bg-fd-card px-3.5 py-2.5 shadow-sm"
|
||||
>
|
||||
<Handle
|
||||
id="ask"
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!opacity-0"
|
||||
style={{ left: "35%" }}
|
||||
/>
|
||||
<Handle
|
||||
id="answer"
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
className="!opacity-0"
|
||||
style={{ left: "65%" }}
|
||||
/>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-fd-primary/15 text-fd-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||
<circle cx="9" cy="12" r="1.25" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="12" r="1.25" fill="currentColor" stroke="none" />
|
||||
<path d="M12 3v3" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-[17px] font-semibold leading-6 text-fd-foreground">
|
||||
{data.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{data.items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded border border-fd-border bg-fd-background px-1.5 py-0.5 text-[12px] leading-5 text-fd-muted-foreground"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HubNodeView({ data }: NodeProps<HubNode>) {
|
||||
return (
|
||||
<div
|
||||
style={{ width: HUB_W, height: HUB_H }}
|
||||
className="relative flex flex-col rounded-md border border-cyan-200/20 bg-[#0f1f23] px-4 py-3.5 text-white shadow-sm dark:bg-[#0b181b]"
|
||||
>
|
||||
<Handle
|
||||
id="ask"
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!opacity-0"
|
||||
style={{ left: "37.5%" }}
|
||||
/>
|
||||
<Handle
|
||||
id="answer"
|
||||
type="source"
|
||||
position={Position.Top}
|
||||
className="!opacity-0"
|
||||
style={{ left: "62.5%" }}
|
||||
/>
|
||||
<Handle
|
||||
id="to-context"
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!opacity-0"
|
||||
style={{ left: "44%" }}
|
||||
/>
|
||||
<Handle
|
||||
id="to-warehouse"
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!opacity-0"
|
||||
style={{ left: "56%" }}
|
||||
/>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-cyan-300/95 font-mono text-sm font-bold text-[#0b1c20]">
|
||||
k
|
||||
</span>
|
||||
<span className="text-[19px] font-bold leading-6 text-white">
|
||||
{data.title}
|
||||
</span>
|
||||
<span className="ml-1 rounded border border-cyan-200/30 bg-white/5 px-1.5 py-0.5 font-mono text-[11px] leading-5 text-cyan-100/85">
|
||||
{data.badge}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-1 flex-col justify-center gap-2">
|
||||
{data.rows.map((row) => (
|
||||
<div key={row} className="flex items-center gap-2.5">
|
||||
<span className="h-1.5 w-1.5 flex-none rounded-full bg-cyan-300/95" />
|
||||
<span className="text-[14px] font-medium leading-5 text-cyan-50/90">
|
||||
{row}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetNodeView({ data }: NodeProps<TargetNode>) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: TARGET_W,
|
||||
height: TARGET_H,
|
||||
borderTop: `3px solid ${data.accent}`,
|
||||
}}
|
||||
className="overflow-hidden rounded-md border border-fd-border bg-fd-card px-3.5 py-3 shadow-sm"
|
||||
>
|
||||
<Handle id="in" type="target" position={Position.Top} className="!opacity-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[17px] font-semibold leading-6 text-fd-foreground">
|
||||
{data.title}
|
||||
</p>
|
||||
{data.badge ? (
|
||||
<span
|
||||
className="rounded-full px-1.5 py-0.5 text-[11px] font-semibold leading-5"
|
||||
style={{
|
||||
color: data.accent,
|
||||
background: "color-mix(in oklch, var(--color-fd-card) 86%, #64748b)",
|
||||
}}
|
||||
>
|
||||
{data.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{data.rows.length > 0 ? (
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
{data.rows.map((row) => (
|
||||
<span
|
||||
key={row.text}
|
||||
className={
|
||||
row.mono
|
||||
? "font-mono text-[13px] font-semibold tracking-tight"
|
||||
: "text-[12px] leading-4 text-fd-muted-foreground"
|
||||
}
|
||||
style={row.color ? { color: row.color } : undefined}
|
||||
>
|
||||
{row.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-1.5 line-clamp-2 text-[13px] leading-[18px] text-fd-muted-foreground">
|
||||
{data.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------- Particles ------------------------------- */
|
||||
|
||||
const PARTICLE_SPEED_PX_PER_SEC = 150;
|
||||
const PARTICLE_MIN_DURATION_SEC = 5;
|
||||
|
||||
type Leg = {
|
||||
sx: number;
|
||||
sy: number;
|
||||
sPos: Position;
|
||||
tx: number;
|
||||
ty: number;
|
||||
tPos: Position;
|
||||
};
|
||||
|
||||
const AGENT_ASK_X = AGENT_X + AGENT_W * 0.35;
|
||||
const AGENT_ANSWER_X = AGENT_X + AGENT_W * 0.65;
|
||||
const AGENT_BOTTOM_Y = ROW_AGENT_Y + AGENT_H;
|
||||
const HUB_ASK_X = HUB_X + HUB_W * 0.375;
|
||||
const HUB_ANSWER_X = HUB_X + HUB_W * 0.625;
|
||||
const HUB_TO_CONTEXT_X = HUB_X + HUB_W * 0.44;
|
||||
const HUB_TO_WAREHOUSE_X = HUB_X + HUB_W * 0.56;
|
||||
const HUB_BOTTOM_Y = ROW_HUB_Y + HUB_H;
|
||||
const CONTEXT_TOP_X = CONTEXT_X + TARGET_W / 2;
|
||||
const WAREHOUSE_TOP_X = WAREHOUSE_X + TARGET_W / 2;
|
||||
|
||||
function buildCyclePath(spokeX: number, targetX: number): {
|
||||
d: string;
|
||||
length: number;
|
||||
} {
|
||||
const legs: Leg[] = [
|
||||
// agent → hub (ask, down)
|
||||
{ sx: AGENT_ASK_X, sy: AGENT_BOTTOM_Y, sPos: Position.Bottom, tx: HUB_ASK_X, ty: ROW_HUB_Y, tPos: Position.Top },
|
||||
// through the hub to its spoke handle (down, drawn behind the hub)
|
||||
{ sx: HUB_ASK_X, sy: ROW_HUB_Y, sPos: Position.Bottom, tx: spokeX, ty: HUB_BOTTOM_Y, tPos: Position.Top },
|
||||
// hub → target (down)
|
||||
{ sx: spokeX, sy: HUB_BOTTOM_Y, sPos: Position.Bottom, tx: targetX, ty: ROW_TARGET_Y, tPos: Position.Top },
|
||||
// target → hub (up)
|
||||
{ sx: targetX, sy: ROW_TARGET_Y, sPos: Position.Top, tx: spokeX, ty: HUB_BOTTOM_Y, tPos: Position.Bottom },
|
||||
// through the hub to its answer handle (up, drawn behind the hub)
|
||||
{ sx: spokeX, sy: HUB_BOTTOM_Y, sPos: Position.Top, tx: HUB_ANSWER_X, ty: ROW_HUB_Y, tPos: Position.Bottom },
|
||||
// hub → agent (answer, up)
|
||||
{ sx: HUB_ANSWER_X, sy: ROW_HUB_Y, sPos: Position.Top, tx: AGENT_ANSWER_X, ty: AGENT_BOTTOM_Y, tPos: Position.Bottom },
|
||||
];
|
||||
|
||||
const segments = legs.map((leg) => {
|
||||
const [segment] = getSmoothStepPath({
|
||||
sourceX: leg.sx,
|
||||
sourceY: leg.sy,
|
||||
sourcePosition: leg.sPos,
|
||||
targetX: leg.tx,
|
||||
targetY: leg.ty,
|
||||
targetPosition: leg.tPos,
|
||||
});
|
||||
return segment;
|
||||
});
|
||||
|
||||
let d = segments[0];
|
||||
for (let i = 1; i < segments.length; i += 1) {
|
||||
d += ` ${segments[i].replace(/^M/, "L")}`;
|
||||
}
|
||||
|
||||
const length = legs.reduce(
|
||||
(sum, leg) => sum + Math.abs(leg.tx - leg.sx) + Math.abs(leg.ty - leg.sy),
|
||||
0,
|
||||
);
|
||||
|
||||
return { d, length };
|
||||
}
|
||||
|
||||
type ParticleEdgeData = {
|
||||
d: string;
|
||||
duration: number;
|
||||
beginOffset: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type ParticleEdge = Edge<ParticleEdgeData, "particle">;
|
||||
|
||||
function ParticleEdgeView({ id, data }: EdgeProps<ParticleEdge>) {
|
||||
if (!data) return null;
|
||||
const pathId = `runtime-particle-path-${id}`;
|
||||
return (
|
||||
<>
|
||||
<path id={pathId} d={data.d} fill="none" stroke="none" pointerEvents="none" />
|
||||
<g className="runtime-particle" style={{ color: data.color }}>
|
||||
<circle r={7.5} fill="currentColor" opacity={0.16} />
|
||||
<circle r={3.75} fill="currentColor" opacity={0.32} />
|
||||
<circle r={2.1} fill="currentColor" />
|
||||
<animateMotion
|
||||
dur={`${data.duration.toFixed(2)}s`}
|
||||
begin={`-${data.beginOffset.toFixed(2)}s`}
|
||||
repeatCount="indefinite"
|
||||
>
|
||||
<mpath href={`#${pathId}`} />
|
||||
</animateMotion>
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeCycleEdge(
|
||||
id: string,
|
||||
source: string,
|
||||
spokeX: number,
|
||||
targetX: number,
|
||||
beginFraction: number,
|
||||
): ParticleEdge {
|
||||
const { d, length } = buildCyclePath(spokeX, targetX);
|
||||
const duration = Math.max(
|
||||
PARTICLE_MIN_DURATION_SEC,
|
||||
length / PARTICLE_SPEED_PX_PER_SEC,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target: source,
|
||||
type: "particle",
|
||||
data: { d, duration, beginOffset: duration * beginFraction, color: CYCLE_STROKE },
|
||||
};
|
||||
}
|
||||
|
||||
const particleEdges: ParticleEdge[] = [
|
||||
makeCycleEdge("p-context", "context", HUB_TO_CONTEXT_X, CONTEXT_TOP_X, 0),
|
||||
makeCycleEdge("p-warehouse", "warehouse", HUB_TO_WAREHOUSE_X, WAREHOUSE_TOP_X, 0.5),
|
||||
];
|
||||
|
||||
const nodeTypes = {
|
||||
agent: AgentNodeView,
|
||||
hub: HubNodeView,
|
||||
target: TargetNodeView,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
particle: ParticleEdgeView,
|
||||
};
|
||||
|
||||
const edges = [...flowEdges, ...particleEdges];
|
||||
|
||||
export function ProductRuntime() {
|
||||
return (
|
||||
<section
|
||||
className="not-prose my-12 w-full max-w-full min-w-0 space-y-5"
|
||||
aria-labelledby="runtime-title"
|
||||
>
|
||||
<div className="max-w-3xl">
|
||||
<h2
|
||||
id="runtime-title"
|
||||
className="text-xl font-semibold tracking-normal text-fd-foreground sm:text-2xl"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
How serving works
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-fd-muted-foreground">
|
||||
At runtime, agents reach ktx through MCP. ktx searches the context
|
||||
layer, returns approved metrics, and compiles them into read-only SQL
|
||||
the warehouse runs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article
|
||||
className="max-w-full min-w-0 overflow-hidden rounded-lg border border-fd-border bg-fd-card shadow-sm"
|
||||
aria-label="ktx serving flow from an agent request to a governed answer"
|
||||
>
|
||||
<div className="border-b border-fd-border bg-fd-muted/35 px-5 py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-fd-primary">
|
||||
Serving flow
|
||||
</p>
|
||||
<h3
|
||||
className="mt-1 text-base font-semibold tracking-normal text-fd-foreground sm:text-lg"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
From an agent request to a governed answer
|
||||
</h3>
|
||||
<p className="mt-2 max-w-3xl text-xs leading-5 text-fd-muted-foreground">
|
||||
The agent asks in plain language. ktx is the only thing that touches
|
||||
the context layer and the warehouse, and every database connection
|
||||
is read-only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FlowCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
canvasStyle={{
|
||||
height: "min(620px, 98vw)",
|
||||
minHeight: 430,
|
||||
}}
|
||||
className="runtime-canvas"
|
||||
fitViewOptions={{ padding: 0.06 }}
|
||||
ariaLabel="ktx serving flow diagram"
|
||||
/>
|
||||
</article>
|
||||
<style>{`
|
||||
.runtime-canvas .runtime-particle {
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 6px currentColor);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.runtime-canvas .runtime-particle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ Read https://docs.kaelio.com/ktx/llms.txt first. Then fetch only the ktx Markdow
|
|||
## Set up a project
|
||||
|
||||
```text
|
||||
Run npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx and use the ktx skill to install
|
||||
Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install
|
||||
and configure ktx in this project.
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,6 @@ configured connection and exit non-zero if any probe fails.
|
|||
| Error | Cause | Recovery |
|
||||
|-------|-------|----------|
|
||||
| No connections configured | The project has no entries under `connections` | Run `ktx setup` and add a database or context-source connection |
|
||||
| Connection test fails | Credentials, network access, database, warehouse, or schema is invalid | Verify the same URL with the database's native client, then rerun `ktx setup` and reconfigure the connection |
|
||||
| Mapping validation fails during setup | BI database mappings do not point at valid warehouse connections | Rerun `ktx setup` and update the context-source mapping selections |
|
||||
| Connection test fails | Credentials, network access, database, warehouse, or schema is invalid | Use the setup recovery menu to retry or re-enter details; if it still fails, verify the same URL with the database's native client |
|
||||
| Mapping validation fails during setup | BI database mappings do not point at valid warehouse connections | Use the setup recovery menu to retry validation or re-enter mapping selections; rerun `ktx setup` if you already exited |
|
||||
| Notion page picker cannot run | The terminal is non-interactive or Notion discovery failed | Rerun interactive `ktx setup`, or use non-interactive setup flags with explicit root page ids |
|
||||
|
|
|
|||
|
|
@ -177,7 +177,9 @@ Slowest phase: reconciliation (2m 05s, 48% of wall time). 2 work units (1 failed
|
|||
|
||||
Work units run serially by default (`ingest.workUnits.maxConcurrency` is `1`);
|
||||
raise it in `ktx.yaml` if the profile shows the run is bound by serialized
|
||||
work-unit agent loops.
|
||||
work-unit agent loops. If the provider reports an LLM rate limit, **ktx** shows
|
||||
a transient wait message and temporarily reduces effective work-unit concurrency
|
||||
according to `ingest.rateLimit`.
|
||||
|
||||
## Common errors
|
||||
|
||||
|
|
|
|||
|
|
@ -51,8 +51,9 @@ prompts.
|
|||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--llm-backend <backend>` | LLM backend: `anthropic`, `vertex`, or `claude-code` |
|
||||
| `--llm-backend <backend>` | LLM backend: `anthropic`, `vertex`, `claude-code`, or `codex` |
|
||||
| `--llm-backend claude-code` | Use the local Claude Code session for **ktx** LLM calls |
|
||||
| `--llm-backend codex` | Use local Codex authentication for **ktx** LLM calls |
|
||||
| `--llm-model <model>` | LLM model ID or backend model alias to validate and save |
|
||||
| `--anthropic-api-key-env <name>` | Environment variable containing the Anthropic API key |
|
||||
| `--anthropic-api-key-file <path>` | File containing the Anthropic API key |
|
||||
|
|
@ -62,9 +63,14 @@ prompts.
|
|||
|
||||
Choose only one Anthropic credential source. Anthropic credential flags are only
|
||||
valid with the Anthropic backend; Vertex flags are only valid with the Vertex
|
||||
backend. The `claude-code` backend uses local Claude Code authentication instead
|
||||
backend. The `claude-code` and `codex` backends use local authentication instead
|
||||
of Anthropic API key or Vertex flags. For Claude Code, `--llm-model` accepts
|
||||
`sonnet`, `opus`, `haiku`, or a full Claude model ID.
|
||||
`sonnet`, `opus`, `haiku`, or a full Claude model ID. For Codex, `--llm-model`
|
||||
accepts `codex`, `default`, or a `gpt-*` / `codex-*` model ID such as
|
||||
`gpt-5.5`; any other value is rejected before the auth probe. Run `codex` to
|
||||
see the models available to your login, and pick a `gpt-*` / `codex-*` id from
|
||||
that list. Note that `*-codex` API-billing model IDs (for example
|
||||
`gpt-5.3-codex`) are not available to ChatGPT-subscription logins.
|
||||
|
||||
### Embeddings
|
||||
|
||||
|
|
@ -142,6 +148,13 @@ fix the prerequisite. If the later schema-context build also fails, interactive
|
|||
setup offers **Disable query history and retry** so you can finish database
|
||||
setup with `connections.<id>.context.queryHistory.enabled: false`.
|
||||
|
||||
After the schema scan completes, setup can derive query-history service-account
|
||||
filters from in-scope history. If **ktx** finds clear operational roles, it
|
||||
prints each proposed exclusion with a reason and writes
|
||||
`connections.<id>.context.queryHistory.filters.serviceAccounts` only when you
|
||||
apply the proposal. In non-interactive setup with `--yes`, the proposal is
|
||||
applied automatically. Existing `serviceAccounts` blocks are never overwritten.
|
||||
|
||||
For BigQuery, the remediation tells you to grant `roles/bigquery.resourceViewer`
|
||||
on the BigQuery project, or grant a custom role that contains
|
||||
`bigquery.jobs.listAll`.
|
||||
|
|
@ -191,6 +204,17 @@ ktx setup \
|
|||
--llm-backend claude-code \
|
||||
--llm-model opus
|
||||
|
||||
# Configure **ktx** to use local Codex authentication for LLM work
|
||||
ktx setup --llm-backend codex --llm-model gpt-5.5 --no-input
|
||||
```
|
||||
|
||||
When you choose `--llm-backend codex`, setup prints a warning if the public
|
||||
Codex SDK and CLI surface cannot prove full Claude-Code-style isolation. The
|
||||
backend restricts **ktx** runtime MCP tools to each run, but Codex may still
|
||||
load user Codex config and built-in command execution or read-only file
|
||||
capabilities.
|
||||
|
||||
```bash
|
||||
# Script a Postgres connection that reads its URL from the environment
|
||||
ktx setup \
|
||||
--project-dir ./analytics \
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ ktx status [options]
|
|||
| `--json` | Print JSON output | `false` |
|
||||
| `-v`, `--verbose` | Show every check, including passing ones | `false` |
|
||||
| `--validate` | Only validate the `ktx.yaml` schema; skip readiness checks | `false` |
|
||||
| `--fast` | Skip checks that require external communication (query-history readiness probes and Claude Code auth probe) | `false` |
|
||||
| `--fast` | Skip checks that require external communication (query-history readiness probes, Claude Code auth probe, and Codex auth probe) | `false` |
|
||||
| `--no-input` | Disable interactive terminal input | - |
|
||||
|
||||
## Examples
|
||||
|
|
@ -39,7 +39,7 @@ ktx status --verbose
|
|||
# Validate ktx.yaml without running readiness checks
|
||||
ktx status --validate
|
||||
|
||||
# Skip slow probes (query-history readiness, Claude Code auth)
|
||||
# Skip slow probes (query-history readiness, Claude Code auth, Codex auth)
|
||||
ktx status --fast
|
||||
|
||||
# Check a project from another directory
|
||||
|
|
@ -57,6 +57,16 @@ flow, then rerun `ktx status`. Use `--fast` to skip this probe (useful in CI
|
|||
or offline contexts); skipped checks render as `-` and carry
|
||||
`"status": "skipped"` in JSON output.
|
||||
|
||||
For `llm.provider.backend: codex`, `ktx status` runs a minimal non-interactive
|
||||
Codex request. If the probe fails, authenticate Codex locally with the Codex CLI
|
||||
and verify the Codex CLI installation.
|
||||
|
||||
When `llm.provider.backend: codex` is configured, `ktx status` also prints a
|
||||
warning when the installed public Codex SDK and CLI surface cannot prove full
|
||||
Claude-Code-style isolation. The warning does not block authenticated Codex
|
||||
usage, but it marks the project status as partial so you can make an explicit
|
||||
runtime-isolation decision.
|
||||
|
||||
A `Local data` section summarises what the project has accumulated locally:
|
||||
ingest run counts, last completed timestamp per connection, knowledge page
|
||||
counts by scope, semantic-layer source and dictionary value counts, and the
|
||||
|
|
|
|||
|
|
@ -74,6 +74,44 @@ The public context-build entrypoint is `ktx ingest [connectionId]` or
|
|||
| `-v`, `--version` | Show the CLI package name and version. |
|
||||
| `-h`, `--help` | Show help for the current command. |
|
||||
|
||||
## Update notices
|
||||
|
||||
> **Note:** The update notifier writes only to stderr and keeps command stdout
|
||||
> unchanged.
|
||||
|
||||
When a newer package is available on your installed release channel, `ktx`
|
||||
prints a short notice after the command finishes:
|
||||
|
||||
```text
|
||||
↑ Update available: ktx 0.9.0 → 0.10.0
|
||||
npm i -g @kaelio/ktx
|
||||
```
|
||||
|
||||
Stable installs compare against the npm `latest` dist-tag.
|
||||
Release-candidate installs compare against the `next` dist-tag and show:
|
||||
|
||||
```text
|
||||
npm i -g @kaelio/ktx@next
|
||||
```
|
||||
|
||||
The check is skipped for JSON output, CI, non-TTY stdout, and hidden completion
|
||||
commands. To opt out explicitly, set any of these environment variables:
|
||||
|
||||
```bash
|
||||
KTX_NO_UPDATE_CHECK=1
|
||||
NO_UPDATE_NOTIFIER=1
|
||||
DO_NOT_TRACK=1
|
||||
```
|
||||
|
||||
The `ktx` CLI prints one npm command because globally installed binaries don't
|
||||
expose a reliable runtime package-manager signal. If you prefer another global
|
||||
package manager, use the equivalent command:
|
||||
|
||||
```bash
|
||||
pnpm add -g @kaelio/ktx
|
||||
yarn global add @kaelio/ktx
|
||||
```
|
||||
|
||||
## Project resolution
|
||||
|
||||
Most commands are project-aware. Pass `--project-dir <path>` when scripting or
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ the core team trade questions, share patterns, and shape the roadmap.
|
|||
| You want to... | Go here |
|
||||
|----------------|---------|
|
||||
| Ask a question or chat with the community | [**ktx** Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ) |
|
||||
| Report a bug or request a feature | [GitHub Issues](https://github.com/Kaelio/ktx-ai-data-agents-context/issues) |
|
||||
| Report a bug or request a feature | [GitHub Issues](https://github.com/Kaelio/ktx/issues) |
|
||||
| Read or contribute to the docs | [docs.kaelio.com/ktx](https://docs.kaelio.com/ktx/docs/) |
|
||||
| Contribute code | [Contributing guide](/docs/community/contributing) |
|
||||
|
||||
|
|
@ -30,14 +30,14 @@ Slack is the right place for:
|
|||
- **Feedback** on the roadmap and early features
|
||||
|
||||
For anything reproducible - a crash, a wrong result, an unexpected CLI error -
|
||||
open a [GitHub issue](https://github.com/Kaelio/ktx-ai-data-agents-context/issues) instead. Issues are
|
||||
open a [GitHub issue](https://github.com/Kaelio/ktx/issues) instead. Issues are
|
||||
searchable, get triaged, and stay attached to the eventual fix.
|
||||
|
||||
## GitHub
|
||||
|
||||
- **[Issues](https://github.com/Kaelio/ktx-ai-data-agents-context/issues)** - bugs and feature requests
|
||||
- **[Pull requests](https://github.com/Kaelio/ktx-ai-data-agents-context/pulls)** - code, docs, and connector contributions
|
||||
- **[Releases](https://github.com/Kaelio/ktx-ai-data-agents-context/releases)** - changelog and published versions
|
||||
- **[Issues](https://github.com/Kaelio/ktx/issues)** - bugs and feature requests
|
||||
- **[Pull requests](https://github.com/Kaelio/ktx/pulls)** - code, docs, and connector contributions
|
||||
- **[Releases](https://github.com/Kaelio/ktx/releases)** - changelog and published versions
|
||||
|
||||
## Code of conduct
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ description: Understand what usage telemetry ktx collects and how to opt out.
|
|||
**ktx** collects aggregated usage telemetry so maintainers can see
|
||||
which commands work, where setup fails, and which parts of the data-agent
|
||||
workflow need improvement. Telemetry is opt-out: it turns on the first time you
|
||||
run **ktx** in an interactive terminal, which prints a one-time notice. From
|
||||
then on the same install also reports background activity that has no terminal
|
||||
of its own, such as the local MCP server your agent calls. It stays disabled in
|
||||
CI, whenever an opt-out is set, and until that first interactive run has shown
|
||||
the notice.
|
||||
run **ktx** in any way — an interactive command, a script, or an
|
||||
agent-launched MCP server — and prints a one-time notice (to the terminal when
|
||||
there is one, otherwise to standard error). It stays disabled in CI and whenever
|
||||
an opt-out is set.
|
||||
|
||||
## Opt out
|
||||
|
||||
|
|
@ -25,10 +24,11 @@ Use any of these mechanisms to disable telemetry:
|
|||
|
||||
## What we collect
|
||||
|
||||
High-level signals only: which commands run, how long they take, whether they
|
||||
High-level signals: which commands run, how long they take, whether they
|
||||
succeed or fail, and basic environment metadata (CLI version, Node version, OS
|
||||
platform). For project-level analysis, **ktx** sends a salted hash of the
|
||||
project directory — never the raw path.
|
||||
platform). When an operation fails, we also include diagnostic detail about the
|
||||
error so we can debug it. For project-level analysis, **ktx** sends a salted
|
||||
hash of the project directory to group events.
|
||||
|
||||
When an agent reaches **ktx** through MCP, we also record the connecting client
|
||||
tool's self-reported name and version (for example Claude Desktop, Cursor, or
|
||||
|
|
@ -37,11 +37,41 @@ tool, never you or your data.
|
|||
|
||||
## What we never collect
|
||||
|
||||
- File paths, hostnames, environment variable values, or command arguments
|
||||
- `ktx.yaml` contents, connection passwords, API keys, or tokens
|
||||
- Schema names, table names, column names, SQL text, or query results
|
||||
- Error messages or stack traces
|
||||
- Git remote URLs, Git user email, OS user, or hostname
|
||||
We build telemetry around counts and coarse signals, not the contents of your
|
||||
data or configuration. We don't deliberately collect your `ktx.yaml`, query
|
||||
results, passwords, API keys, or access tokens.
|
||||
|
||||
The one place environment-specific text can appear is failure diagnostics: when
|
||||
an operation errors, the detail we record is the error as your tools reported
|
||||
it, which can include identifiers from your setup. If you'd rather send nothing
|
||||
at all, turn telemetry off using any of the options above.
|
||||
|
||||
## Error reports
|
||||
|
||||
When telemetry is enabled, **ktx** sends PostHog Error Tracking `$exception`
|
||||
events for CLI and daemon exceptions. Error reports help group crashes and
|
||||
handled failures into PostHog issues.
|
||||
|
||||
Error reports can include:
|
||||
|
||||
- Stack frames, including function names, local file paths, line numbers, and
|
||||
SDK-provided source context.
|
||||
- Error class names and raw error messages.
|
||||
- Cause chains when the runtime exposes them.
|
||||
- `source`, `handled`, and `fatal` diagnostic fields.
|
||||
- Runtime version, OS, architecture, and CI fields.
|
||||
- The hashed `projectId` when **ktx** knows the project.
|
||||
|
||||
Error reports never intentionally include:
|
||||
|
||||
- Secrets, credentials, API keys, tokens, cookies, signed URLs, or auth headers.
|
||||
- Database URLs, connection strings, DSNs, raw argv, or raw environment values.
|
||||
- SQL text, schema names, table names, or column names as explicit payload
|
||||
properties.
|
||||
- Customer row data.
|
||||
- User prompt text or raw MCP arguments.
|
||||
|
||||
The same opt-out controls listed above disable error reports.
|
||||
|
||||
## Storage and retention
|
||||
|
||||
|
|
|
|||
|
|
@ -179,9 +179,22 @@ connections:
|
|||
context:
|
||||
queryHistory:
|
||||
enabled: true
|
||||
enabledSchemas:
|
||||
- orbit_raw
|
||||
- orbit_analytics
|
||||
minExecutions: 5
|
||||
```
|
||||
|
||||
- `enabledSchemas`: Optional list of schema or dataset names that query-history
|
||||
ingest may mine. Omit it to let **ktx** derive the modeled schema floor from
|
||||
the connection and semantic-layer sources. Use `["*"]` to disable the floor
|
||||
for discovery runs.
|
||||
- `filters.serviceAccounts`: Optional service-account filter block. During
|
||||
setup, when query history is enabled and no service-account block already
|
||||
exists, **ktx** can propose exact role patterns such as `^svc_loader$` from
|
||||
observed in-scope query history. The block uses `mode: exclude` and remains
|
||||
hand-editable.
|
||||
|
||||
### Metabase
|
||||
|
||||
```yaml
|
||||
|
|
@ -376,13 +389,23 @@ llm:
|
|||
|
||||
| Field | Type | Default | Purpose |
|
||||
|-------|------|---------|---------|
|
||||
| `provider.backend` | `none` \| `anthropic` \| `vertex` \| `gateway` \| `claude-code` | `none` | Selected backend. `none` disables LLM features. `claude-code` uses the local Claude Code session and needs no API key. |
|
||||
| `provider.backend` | `none` \| `anthropic` \| `vertex` \| `gateway` \| `claude-code` \| `codex` | `none` | Selected backend. `none` disables LLM features. `claude-code` uses the local Claude Code session and needs no API key. `codex` uses local Codex authentication and needs no API key. |
|
||||
| `provider.anthropic.api_key` | `string` | - | Anthropic API key. Required when `backend: anthropic`. Accepts `env:` or `file:` references. |
|
||||
| `provider.anthropic.base_url` | `string` | - | Override the Anthropic API base URL (proxy, self-hosted gateway). |
|
||||
| `provider.gateway.api_key` / `base_url` | `string` | - | Credentials for an AI Gateway provider. Required when `backend: gateway`. |
|
||||
| `provider.vertex.project` | `string` | - | Google Cloud project ID hosting the Vertex AI endpoint. |
|
||||
| `provider.vertex.location` | `string` | - | Vertex AI region (for example `us-east5`). Required when the `vertex` block is present. |
|
||||
|
||||
Use `codex` when local Codex authentication should power **ktx** LLM work:
|
||||
|
||||
```yaml
|
||||
llm:
|
||||
provider:
|
||||
backend: codex
|
||||
models:
|
||||
default: gpt-5.5
|
||||
```
|
||||
|
||||
### Model roles
|
||||
|
||||
`models` overrides the per-role model. Keys are fixed; values are
|
||||
|
|
@ -429,6 +452,16 @@ ingest:
|
|||
stepBudget: 40
|
||||
maxConcurrency: 2
|
||||
failureMode: continue
|
||||
rateLimit:
|
||||
enabled: true
|
||||
throttleThreshold: 0.8
|
||||
minConcurrencyUnderPressure: 1
|
||||
maxWaitMs: 600000
|
||||
retry:
|
||||
maxAttempts: 6
|
||||
baseDelayMs: 1000
|
||||
maxDelayMs: 60000
|
||||
jitter: true
|
||||
```
|
||||
|
||||
### Adapters
|
||||
|
|
@ -475,6 +508,24 @@ handles failures.
|
|||
| `workUnits.maxConcurrency` | `int > 0` | `1` | How many work units run in parallel. |
|
||||
| `workUnits.failureMode` | `abort` \| `continue` | `continue` | `abort` stops the whole ingest run on the first failure; `continue` records it and keeps going. |
|
||||
|
||||
### Rate limits
|
||||
|
||||
`rateLimit` controls provider-neutral pacing for LLM calls during ingest. When a
|
||||
provider reports a subscription window, retry-after delay, or HTTP 429,
|
||||
**ktx** pauses new work-unit model calls, shows a transient wait in the CLI,
|
||||
and reduces work-unit concurrency while the provider is under pressure.
|
||||
|
||||
| Field | Type | Default | Purpose |
|
||||
|-------|------|---------|---------|
|
||||
| `rateLimit.enabled` | `boolean` | `true` | Master switch for ingest LLM rate-limit pacing and visible waits. |
|
||||
| `rateLimit.throttleThreshold` | `number between 0 and 1` | `0.8` | Fraction of a known provider window at which **ktx** starts reducing concurrency. |
|
||||
| `rateLimit.minConcurrencyUnderPressure` | `int > 0` | `1` | Effective work-unit concurrency while a provider is under rate-limit pressure. |
|
||||
| `rateLimit.maxWaitMs` | `int > 0` | unset | Caps how long a single provider-reset wait can last. This bounds each wait, not the whole run: after a capped wait elapses **ktx** retries and may pause again. Omit to wait until the provider's reset time. |
|
||||
| `rateLimit.retry.maxAttempts` | `int > 0` | `6` | Maximum attempts for a single rate-limited LLM call before the failure surfaces (counts the first try). Also bounds how far opaque backoff grows for responses without a reset time or retry-after value. |
|
||||
| `rateLimit.retry.baseDelayMs` | `int > 0` | `1000` | Initial opaque retry delay in milliseconds. |
|
||||
| `rateLimit.retry.maxDelayMs` | `int > 0` | `60000` | Maximum opaque retry delay in milliseconds. |
|
||||
| `rateLimit.retry.jitter` | `boolean` | `true` | Add jitter to opaque retry delays. |
|
||||
|
||||
## `scan`
|
||||
|
||||
`scan` configures how schema-level inputs become structured context:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ description: ktx is an open-source, self-improving context layer for data agents
|
|||
---
|
||||
|
||||
import { ProductMechanics } from "@/components/product-mechanics";
|
||||
import { ProductRuntime } from "@/components/product-runtime";
|
||||
|
||||
<div className="not-prose mb-10">
|
||||
<div>
|
||||
|
|
@ -59,6 +60,8 @@ serves that context to agents at runtime.
|
|||
|
||||
<ProductMechanics />
|
||||
|
||||
<ProductRuntime />
|
||||
|
||||
## Use it for
|
||||
|
||||
Use **ktx** when agents need more than raw database access. Agents can search wiki
|
||||
|
|
@ -102,6 +105,6 @@ best first step for users; contributor setup lives in the community docs.
|
|||
Have questions, want to share what you're building, or chat with maintainers?
|
||||
Join the [**ktx** Slack community](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ).
|
||||
For bug reports and feature requests, open a
|
||||
[GitHub issue](https://github.com/Kaelio/ktx-ai-data-agents-context/issues). See
|
||||
[GitHub issue](https://github.com/Kaelio/ktx/issues). See
|
||||
[Community & Support](/docs/community/support) for the full guide on where to
|
||||
ask what.
|
||||
|
|
|
|||
|
|
@ -171,14 +171,14 @@ If you're a coding assistant choosing a docs route, start with the
|
|||
</span>
|
||||
<CopyButton
|
||||
text={[
|
||||
'Run npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx and use the ktx skill',
|
||||
'Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill',
|
||||
'to install and configure ktx',
|
||||
].join(' ')}
|
||||
className="-my-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 font-mono text-[13.5px] leading-6 text-fd-foreground">
|
||||
Run {'`npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx`'} and use the ktx skill to install and configure ktx
|
||||
Run {'`npx skills add Kaelio/ktx --skill ktx`'} and use the ktx skill to install and configure ktx
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -215,8 +215,8 @@ The wizard walks you through everything **ktx** needs in one pass:
|
|||
SQLite, PostgreSQL, MySQL, SQL Server, BigQuery, and Snowflake.
|
||||
5. **Context sources** - optionally adds dbt, MetricFlow, LookML, Looker,
|
||||
Metabase, or Notion. You can skip and add them later.
|
||||
6. **Build** - runs the first ingest so semantic sources and wiki pages
|
||||
are ready for agents.
|
||||
6. **Build** - offers to run the first ingest so semantic sources and wiki
|
||||
pages are ready for agents. If you skip it, build later with `ktx ingest`.
|
||||
7. **Agent integration** - installs project-local rules for Claude Code,
|
||||
Codex, Cursor, OpenCode, or universal `.agents`.
|
||||
|
||||
|
|
@ -247,6 +247,18 @@ progress under `.ktx/setup/` and resumes from the remaining work.
|
|||
> resuming setup, connecting an agent, checking status, or exploring a
|
||||
> pre-built demo project.
|
||||
|
||||
When the wizard finishes, it states where you stand and the single next action:
|
||||
|
||||
- **Context built** - **ktx** confirms it is ready for agents and points you to
|
||||
open your coding agent and ask a data question.
|
||||
- **Build skipped** - **ktx** tells you setup is complete and that the only step
|
||||
left is to build context with `ktx ingest`.
|
||||
|
||||
Re-running `ktx setup` on an already-configured project goes straight to the
|
||||
remaining step - building context or connecting an agent - instead of
|
||||
re-asking every question. Once everything is ready, it confirms you are set
|
||||
rather than reopening the configuration menu.
|
||||
|
||||
## Verify
|
||||
|
||||
When setup finishes, check readiness:
|
||||
|
|
@ -268,6 +280,9 @@ Agent integration ready: yes (codex:project)
|
|||
|
||||
For a structured check inside scripts, use `ktx status --json`.
|
||||
|
||||
If you skipped the build, `ktx context built` shows `no`. Build it with
|
||||
`ktx ingest` - there is no need to re-run `ktx setup`.
|
||||
|
||||
When setup finishes building context, its final context check looks like:
|
||||
|
||||
```text
|
||||
|
|
@ -280,6 +295,26 @@ Context sources:
|
|||
dbt_main: memory update complete
|
||||
```
|
||||
|
||||
Before the build starts, **ktx** runs a live test for every connection the
|
||||
build depends on. A context build can take several minutes, so if any required
|
||||
connection is unreachable or misconfigured the build is blocked up front and
|
||||
**ktx** names the failing connection by id and connector type:
|
||||
|
||||
```text
|
||||
KTX cannot build context: a required connection failed its live test.
|
||||
|
||||
Failed connections:
|
||||
warehouse (postgres)
|
||||
|
||||
Each connection must be reachable before KTX builds context.
|
||||
Run `ktx connection test <id>` to see the error, fix the connection, then retry.
|
||||
```
|
||||
|
||||
Run `ktx connection test <connection-id>` to see the underlying error, fix the
|
||||
connection, then continue. In interactive setup you can retry without
|
||||
restarting; with `--no-input` the build exits non-zero and names the failing
|
||||
connection so scripts can stop early.
|
||||
|
||||
## Connect a coding agent
|
||||
|
||||
The setup wizard installs project-local agent rules in the last step. To
|
||||
|
|
@ -339,7 +374,8 @@ surface.
|
|||
| `ktx: command not found` | Reinstall `@kaelio/ktx` and open a new shell |
|
||||
| Setup resumes the wrong project | Pass `--project-dir <path>` |
|
||||
| LLM or embeddings health check fails | Rerun setup and pick a different credential, model, or backend |
|
||||
| Database test fails | Verify the same connection with the database's native client, then rerun setup |
|
||||
| Database test fails | Use the setup recovery menu to retry or re-enter details; if it still fails, verify the same connection with the database's native client |
|
||||
| Context build blocked: a connection failed its live test | Run `ktx connection test <connection-id>` to see the error, fix the connection, then retry the build |
|
||||
| Agent integration is incomplete | Run `ktx setup --agents --target <target>` |
|
||||
|
||||
## Next steps
|
||||
|
|
|
|||
|
|
@ -39,13 +39,28 @@ ktx ingest --all
|
|||
Enriched ingest needs a configured model and embeddings. Run `ktx setup` first;
|
||||
connections without that configuration fail before any work starts.
|
||||
|
||||
With `claude-code`, **ktx** agent loops can invoke only the **ktx** MCP tools for the
|
||||
current run.
|
||||
Local-auth backends keep provider credentials out of `ktx.yaml`:
|
||||
|
||||
```bash
|
||||
ktx setup --llm-backend claude-code --no-input
|
||||
ktx setup --llm-backend codex --llm-model gpt-5.5 --no-input
|
||||
```
|
||||
|
||||
With `claude-code`, **ktx** agent loops can invoke only the **ktx** MCP tools
|
||||
for the current run. With `codex`, **ktx** restricts the temporary runtime MCP
|
||||
server to the current run's tool set, disables Codex web search, requests a
|
||||
read-only sandbox, and sets `approval_policy=never`. The public Codex SDK and
|
||||
CLI surface may still load user Codex config and built-in command execution or
|
||||
read-only file capabilities, so use `claude-code` for stricter runtime tool
|
||||
isolation.
|
||||
|
||||
## Query history
|
||||
|
||||
PostgreSQL, BigQuery, and Snowflake can add query-history context: common joins,
|
||||
filters, service-account patterns, redaction rules, and high-usage templates.
|
||||
filters, redaction rules, high-usage templates, and service-account exclusions.
|
||||
When query history is enabled during setup, **ktx** reviews observed in-scope
|
||||
roles and can write exact `filters.serviceAccounts` patterns for operational
|
||||
traffic such as loader or refresh roles.
|
||||
|
||||
Enable it during setup, store it under `connections.<id>.context.queryHistory`,
|
||||
or request it for one run:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Set `llm.provider.backend` to one of these values:
|
|||
- `gateway`: Use AI Gateway-compatible Anthropic model ids.
|
||||
- `claude-code`: Use your local Claude Code session through the Claude Agent
|
||||
SDK. **ktx** strips provider-routing environment variables from child processes.
|
||||
- `codex`: Use your local Codex authentication through the Codex SDK.
|
||||
|
||||
## Claude Code
|
||||
|
||||
|
|
@ -47,6 +48,42 @@ model IDs are also accepted.
|
|||
metadata may still list host slash commands, skills, and subagents; **ktx** does not
|
||||
grant execution access to them.
|
||||
|
||||
## Codex backend
|
||||
|
||||
Use `codex` when you want **ktx** to run LLM-backed workflows through your
|
||||
local Codex authentication instead of a direct provider API key.
|
||||
|
||||
```yaml
|
||||
llm:
|
||||
provider:
|
||||
backend: codex
|
||||
models:
|
||||
default: gpt-5.5
|
||||
```
|
||||
|
||||
Configure it non-interactively:
|
||||
|
||||
```bash
|
||||
ktx setup --llm-backend codex --llm-model gpt-5.5 --no-input
|
||||
```
|
||||
|
||||
This is separate from Codex agent-client setup. `ktx setup --agents --target
|
||||
codex` installs instructions and MCP access for an end-user Codex session.
|
||||
`ktx setup --llm-backend codex` makes **ktx** itself execute ingest, scan
|
||||
enrichment, memory, and other LLM-backed work through Codex.
|
||||
|
||||
During runtime loops, **ktx** starts a temporary loopback MCP server for the
|
||||
current run, exposes only the tools passed to that run, asks Codex to use a
|
||||
read-only sandbox, sets `approval_policy=never`, auto-approves only those
|
||||
run-scoped MCP tools, and disables Codex web search.
|
||||
|
||||
Codex backend isolation is currently limited by the public Codex SDK and CLI
|
||||
surface. Codex may still load user Codex config and built-in command execution
|
||||
or read-only file capabilities. Use `llm.provider.backend: claude-code` when
|
||||
you need stricter Claude-Code-style runtime tool isolation, or remove host
|
||||
Codex MCP and tool config before running untrusted prompts through the `codex`
|
||||
backend.
|
||||
|
||||
## Prompt caching
|
||||
|
||||
`llm.promptCaching` has partial parity on `claude-code`. Status and doctor warn
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ ktx provides semantic-layer files, warehouse scans, wiki pages, provenance, and
|
|||
|
||||
## Agent Entry Points
|
||||
|
||||
- Installable setup skill: run \`npx skills add Kaelio/ktx-ai-data-agents-context --skill ktx\` from
|
||||
- Installable setup skill: run \`npx skills add Kaelio/ktx --skill ktx\` from
|
||||
the project you want to configure.
|
||||
${link("/docs/ai-resources/agent-quickstart", "Agent Quickstart", "Task-first route for coding assistants using ktx")}
|
||||
${link("/docs/ai-resources/markdown-access", "Markdown Access", "Fetch ktx docs as llms.txt, llms-full.txt, or per-page Markdown")}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ test("product mechanics component explains ingestion outputs", async () => {
|
|||
"compile into SQL",
|
||||
'"use client"',
|
||||
"@xyflow/react",
|
||||
"<ReactFlow",
|
||||
"<FlowCanvas",
|
||||
"getSmoothStepPath",
|
||||
"animateMotion",
|
||||
"mechanics-particle",
|
||||
|
|
@ -97,21 +97,21 @@ test("product mechanics component explains ingestion outputs", async () => {
|
|||
);
|
||||
}
|
||||
|
||||
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/);
|
||||
|
|
|
|||
74
docs-site/tests/product-runtime-content.test.mjs
Normal file
74
docs-site/tests/product-runtime-content.test.mjs
Normal file
|
|
@ -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, /<ProductRuntime\s*\/>/);
|
||||
|
||||
const mechanicsIndex = introduction.indexOf("<ProductMechanics />");
|
||||
const runtimeIndex = introduction.indexOf("<ProductRuntime />");
|
||||
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, /<img/);
|
||||
});
|
||||
|
|
@ -26,7 +26,7 @@ The workflow rejects releases from any branch other than `main`.
|
|||
Before you publish, confirm these requirements:
|
||||
|
||||
- npm Trusted Publishing is configured for `@kaelio/ktx`.
|
||||
- The trusted publisher points at the `Kaelio/ktx-ai-data-agents-context` repository and the
|
||||
- The trusted publisher points at the `Kaelio/ktx` repository and the
|
||||
`.github/workflows/release.yml` workflow.
|
||||
- The workflow keeps `id-token: write` permission so npm can verify the
|
||||
GitHub Actions run through OpenID Connect.
|
||||
|
|
@ -35,15 +35,6 @@ Before you publish, confirm these requirements:
|
|||
- The repository has a stable baseline tag when you need semantic-release to
|
||||
publish the first stable version as `0.1.0`.
|
||||
|
||||
If you rename the GitHub repository, the semantic-release run adapts on its
|
||||
own: `scripts/semantic-release-config.cjs` derives `repositoryUrl` from the
|
||||
runner's `GITHUB_REPOSITORY`, so `@semantic-release/github` always matches the
|
||||
current clone URL. The one thing that does **not** auto-update is the npm
|
||||
Trusted Publishing config — re-point it at the new repository name (plus
|
||||
`release.yml`) on npm, or `npm publish --provenance` will fail OIDC
|
||||
verification. The `repository` field in `package.json` is npm-display metadata
|
||||
only and can stay whatever public name you prefer.
|
||||
|
||||
semantic-release doesn't support choosing an arbitrary first `0.x` stable
|
||||
release. If KTX has no stable tag yet and you need the first stable release to
|
||||
be `0.1.0`, create and push the baseline tag once before running the live
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@
|
|||
"@semantic-release/release-notes-generator",
|
||||
"conventional-changelog-conventionalcommits"
|
||||
],
|
||||
"ignore": [
|
||||
".context/**"
|
||||
],
|
||||
"ignoreBinaries": [
|
||||
"uv",
|
||||
"lsof"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ktx-workspace",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Workspace root for ktx packages",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
"setup:dev": "node scripts/setup-dev.mjs",
|
||||
"release:published-smoke": "node scripts/published-package-smoke.mjs --require-config",
|
||||
"release:local-embeddings-smoke": "node scripts/local-embeddings-runtime-smoke.mjs --require-opt-in",
|
||||
"release:codex-backend-smoke": "node scripts/codex-backend-live-smoke.mjs",
|
||||
"release:readiness": "node scripts/release-readiness.mjs",
|
||||
"release:update-version": "node scripts/update-public-release-version.mjs",
|
||||
"relationships:acquire-public-fixtures": "node scripts/acquire-public-benchmark-fixtures.mjs",
|
||||
|
|
@ -76,10 +77,10 @@
|
|||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Kaelio/ktx-ai-data-agents-context.git"
|
||||
"url": "git+https://github.com/Kaelio/ktx.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Kaelio/ktx-ai-data-agents-context/issues"
|
||||
"url": "https://github.com/Kaelio/ktx/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Kaelio/ktx-ai-data-agents-context#readme"
|
||||
"homepage": "https://github.com/Kaelio/ktx#readme"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
{
|
||||
"name": "@kaelio/ktx",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Standalone ktx context layer for data agents",
|
||||
"author": {
|
||||
"name": "Kaelio",
|
||||
"url": "https://www.kaelio.com"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=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",
|
||||
|
|
@ -56,6 +61,7 @@
|
|||
"@looker/sdk-rtl": "^21.6.5",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@notionhq/client": "^5.22.0",
|
||||
"@openai/codex-sdk": "^0.133.0",
|
||||
"ai": "^6.0.188",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"commander": "14.0.3",
|
||||
|
|
@ -71,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",
|
||||
|
|
@ -84,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",
|
||||
|
|
@ -93,11 +101,11 @@
|
|||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Kaelio/ktx-ai-data-agents-context",
|
||||
"url": "https://github.com/Kaelio/ktx",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Kaelio/ktx-ai-data-agents-context/issues"
|
||||
"url": "https://github.com/Kaelio/ktx/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Kaelio/ktx-ai-data-agents-context#readme"
|
||||
"homepage": "https://github.com/Kaelio/ktx#readme"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<number>;
|
||||
}
|
||||
|
||||
type KtxCliUpdateCheckOptions = Pick<PrepareUpdateCheckNoticeOptions, 'env' | 'fetchDistTags' | 'homeDir' | 'now'>;
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,88 @@ export async function runInitForCommander(
|
|||
return await runInit(args, io);
|
||||
}
|
||||
|
||||
function signalExitCode(signal: NodeJS.Signals): number {
|
||||
// 128 + signal number: SIGINT (2) -> 130, SIGTERM (15) -> 143.
|
||||
return signal === 'SIGTERM' ? 143 : 130;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush telemetry on interrupt for the real CLI process. `capture()` is
|
||||
* fire-and-forget and the only flush guarantee lives in a `finally` a signal
|
||||
* skips, so Ctrl-C / `kill` of a long-running command (ingest, `mcp stdio`)
|
||||
* would otherwise drop its `command` event and queued events. Installed only
|
||||
* when driving the actual process; programmatic/test callers pass their own
|
||||
* `io` and never reach here. Returns a disposer that removes the listeners.
|
||||
*/
|
||||
function installTelemetrySignalFlush(io: KtxCliIo, info: KtxCliPackageInfo): () => void {
|
||||
let handling = false;
|
||||
const handle = (signal: NodeJS.Signals): void => {
|
||||
if (handling) {
|
||||
process.exit(signalExitCode(signal));
|
||||
}
|
||||
handling = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const { emitAbortedCommandAndShutdown } = await import('./telemetry/index.js');
|
||||
await emitAbortedCommandAndShutdown({ packageInfo: info, io });
|
||||
} catch {
|
||||
// Best-effort: never let a telemetry hiccup block the interrupt exit.
|
||||
}
|
||||
process.exit(signalExitCode(signal));
|
||||
})();
|
||||
};
|
||||
const onSigint = (): void => handle('SIGINT');
|
||||
const onSigterm = (): void => handle('SIGTERM');
|
||||
process.on('SIGINT', onSigint);
|
||||
process.on('SIGTERM', onSigterm);
|
||||
return () => {
|
||||
process.off('SIGINT', onSigint);
|
||||
process.off('SIGTERM', onSigterm);
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createGlobalExceptionReporter(io: KtxCliIo, info: KtxCliPackageInfo) {
|
||||
return async (source: 'uncaughtException' | 'unhandledRejection', error: unknown): Promise<void> => {
|
||||
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,
|
||||
|
|
@ -98,7 +180,17 @@ export async function runKtxCli(
|
|||
profileMark('runtime:runKtxCli');
|
||||
const { runCommanderKtxCli } = await profileSpan('import ./cli-program.js', () => import('./cli-program.js'));
|
||||
|
||||
return await runCommanderKtxCli(argv, io, deps, info, {
|
||||
runInit: runInitForCommander,
|
||||
});
|
||||
// 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?.();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
|
|||
}
|
||||
|
||||
function llmBackend(value: string): KtxSetupLlmBackend {
|
||||
if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') {
|
||||
if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError(`invalid choice '${value}'`);
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
132
packages/cli/src/connection-recovery.ts
Normal file
132
packages/cli/src/connection-recovery.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import type { KtxSetupPromptOption } from './setup-prompts.js';
|
||||
|
||||
export type RecoveryOutcome = 'ready' | 'skip' | 'back' | 'failed';
|
||||
|
||||
/** @internal */
|
||||
export interface RecoveryAction {
|
||||
value: string;
|
||||
label: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type ConfigureResult = 'configured' | 'back' | 'cancelled';
|
||||
|
||||
export type ValidateResult =
|
||||
| { status: 'ok' }
|
||||
| { status: 'back' }
|
||||
| { status: 'failed'; extraActions?: RecoveryAction[] };
|
||||
|
||||
export interface ConnectionRecoveryInput {
|
||||
label: string;
|
||||
interactive: boolean;
|
||||
allowSkip: boolean;
|
||||
io: KtxCliIo;
|
||||
prompts: {
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
};
|
||||
snapshot: () => Promise<() => Promise<void>>;
|
||||
configure: () => Promise<ConfigureResult>;
|
||||
validate: () => Promise<ValidateResult>;
|
||||
}
|
||||
|
||||
async function runRollbackOnce(input: {
|
||||
rollback: () => Promise<void>;
|
||||
state: { rolledBack: boolean };
|
||||
}): Promise<void> {
|
||||
if (input.state.rolledBack) {
|
||||
return;
|
||||
}
|
||||
input.state.rolledBack = true;
|
||||
await input.rollback();
|
||||
}
|
||||
|
||||
function recoveryOptions(input: {
|
||||
allowSkip: boolean;
|
||||
extraActions?: RecoveryAction[];
|
||||
}): KtxSetupPromptOption[] {
|
||||
return [
|
||||
{ value: 'retry', label: 'Retry connection test' },
|
||||
{ value: 're-enter', label: 'Re-enter connection details' },
|
||||
...(input.extraActions ?? []).map((action) => ({
|
||||
value: action.value,
|
||||
label: action.label,
|
||||
})),
|
||||
...(input.allowSkip ? [{ value: 'skip', label: 'Skip this connection' }] : []),
|
||||
{ value: 'back', label: 'Back' },
|
||||
];
|
||||
}
|
||||
|
||||
export async function runConnectionSetupWithRecovery(
|
||||
input: ConnectionRecoveryInput,
|
||||
): Promise<RecoveryOutcome> {
|
||||
const rollback = await input.snapshot();
|
||||
const rollbackState = { rolledBack: false };
|
||||
|
||||
const firstConfig = await input.configure();
|
||||
if (firstConfig === 'back') {
|
||||
await runRollbackOnce({ rollback, state: rollbackState });
|
||||
return 'back';
|
||||
}
|
||||
if (firstConfig === 'cancelled') {
|
||||
await runRollbackOnce({ rollback, state: rollbackState });
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
let validation = await input.validate();
|
||||
while (validation.status !== 'ok') {
|
||||
if (validation.status === 'back') {
|
||||
await runRollbackOnce({ rollback, state: rollbackState });
|
||||
return 'back';
|
||||
}
|
||||
|
||||
if (!input.interactive) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const action = await input.prompts.select({
|
||||
message: `Connection setup failed for ${input.label}`,
|
||||
options: recoveryOptions({
|
||||
allowSkip: input.allowSkip,
|
||||
extraActions: validation.extraActions,
|
||||
}),
|
||||
});
|
||||
|
||||
if (action === 'back') {
|
||||
await runRollbackOnce({ rollback, state: rollbackState });
|
||||
return 'back';
|
||||
}
|
||||
if (action === 'skip' && input.allowSkip) {
|
||||
await runRollbackOnce({ rollback, state: rollbackState });
|
||||
return 'skip';
|
||||
}
|
||||
if (action === 're-enter') {
|
||||
const nextConfig = await input.configure();
|
||||
if (nextConfig === 'back') {
|
||||
await runRollbackOnce({ rollback, state: rollbackState });
|
||||
return 'back';
|
||||
}
|
||||
if (nextConfig === 'cancelled') {
|
||||
await runRollbackOnce({ rollback, state: rollbackState });
|
||||
return 'failed';
|
||||
}
|
||||
validation = await input.validate();
|
||||
continue;
|
||||
}
|
||||
if (action === 'retry') {
|
||||
validation = await input.validate();
|
||||
continue;
|
||||
}
|
||||
|
||||
const extraAction = validation.extraActions?.find((candidate) => candidate.value === action);
|
||||
if (extraAction) {
|
||||
await extraAction.run();
|
||||
validation = await input.validate();
|
||||
continue;
|
||||
}
|
||||
|
||||
validation = await input.validate();
|
||||
}
|
||||
|
||||
return 'ready';
|
||||
}
|
||||
|
|
@ -16,8 +16,9 @@ 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 { scrubErrorClass } from './telemetry/scrubber.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 };
|
||||
|
|
@ -304,6 +311,7 @@ async function emitConnectionTest(input: {
|
|||
io: KtxCliIo;
|
||||
}): Promise<void> {
|
||||
const errorClass = input.error ? scrubErrorClass(input.error) : undefined;
|
||||
const errorDetail = input.error ? formatErrorDetail(input.error) : undefined;
|
||||
await emitTelemetryEvent({
|
||||
name: 'connection_test',
|
||||
projectDir: input.project.projectDir,
|
||||
|
|
@ -314,8 +322,24 @@ async function emitConnectionTest(input: {
|
|||
outcome: input.outcome,
|
||||
durationMs: input.durationMs,
|
||||
...(errorClass ? { errorClass } : {}),
|
||||
...(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 {
|
||||
|
|
|
|||
|
|
@ -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<KtxConnectorTestResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KtxConnectorTestResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KtxConnectorTestResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KtxConnectorTestResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KtxConnectorTestResult> {
|
||||
return this.getDriver().test();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KtxConnectorTestResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<KtxConnectorTestResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import type { KtxProgressPort, KtxProgressUpdateOptions } from './context/scan/types.js';
|
||||
import type { KtxCliIo } from './index.js';
|
||||
import type { KtxIngestProgressUpdate } from './ingest.js';
|
||||
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
||||
import { publicDatabaseIngestMessage, publicQueryHistoryMessage } from './public-ingest-copy.js';
|
||||
import type {
|
||||
KtxPublicIngestArgs,
|
||||
KtxPublicIngestDeps,
|
||||
|
|
@ -10,7 +8,8 @@ import type {
|
|||
KtxPublicIngestProject,
|
||||
KtxPublicIngestTargetResult,
|
||||
} from './public-ingest.js';
|
||||
import { buildPublicIngestPlan, executePublicIngestTarget } from './public-ingest.js';
|
||||
import { buildPublicIngestPlan, executePublicIngestTarget, publicProgressMessage } from './public-ingest.js';
|
||||
import { createAggregateProgressPort } from './progress-port-adapter.js';
|
||||
import { formatDuration } from './demo-metrics.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
|
|
@ -810,17 +809,6 @@ export function initViewState(targets: KtxPublicIngestPlanTarget[]): ContextBuil
|
|||
};
|
||||
}
|
||||
|
||||
function publicProgressMessage(message: string, target: KtxPublicIngestPlanTarget): string {
|
||||
let current = message;
|
||||
if (target.operation === 'database-ingest') {
|
||||
current = publicDatabaseIngestMessage(current);
|
||||
}
|
||||
if (target.steps.includes('query-history')) {
|
||||
current = publicQueryHistoryMessage(current, target.connectionId);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function formatProgressDetail(
|
||||
update: Pick<KtxIngestProgressUpdate, 'percent' | 'message'>,
|
||||
target: KtxPublicIngestPlanTarget,
|
||||
|
|
@ -829,29 +817,6 @@ function formatProgressDetail(
|
|||
return `[${percent}%] ${publicProgressMessage(update.message, target)}`;
|
||||
}
|
||||
|
||||
function createContextBuildProgressPort(
|
||||
onProgress: (update: KtxIngestProgressUpdate) => void,
|
||||
state: { progress: number } = { progress: 0 },
|
||||
start = 0,
|
||||
weight = 1,
|
||||
): KtxProgressPort {
|
||||
return {
|
||||
async update(value: number, message?: string, options?: KtxProgressUpdateOptions): Promise<void> {
|
||||
const absoluteValue = start + Math.max(0, Math.min(1, value)) * weight;
|
||||
state.progress = Math.max(state.progress, Math.min(1, absoluteValue));
|
||||
if (!message) return;
|
||||
onProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(state.progress * 100))),
|
||||
message,
|
||||
...(options?.transient !== undefined ? { transient: options.transient } : {}),
|
||||
});
|
||||
},
|
||||
startPhase(phaseWeight: number): KtxProgressPort {
|
||||
return createContextBuildProgressPort(onProgress, state, state.progress, weight * phaseWeight);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runContextBuild(
|
||||
project: KtxPublicIngestProject,
|
||||
args: ContextBuildArgs,
|
||||
|
|
@ -1022,7 +987,7 @@ export async function runContextBuild(
|
|||
};
|
||||
|
||||
const progressDeps: KtxPublicIngestDeps = {
|
||||
scanProgress: createContextBuildProgressPort(updateSchemaPhase),
|
||||
scanProgress: createAggregateProgressPort(updateSchemaPhase),
|
||||
ingestProgress: updateIngestPhase,
|
||||
runtimeIo: io,
|
||||
onPhaseStart,
|
||||
|
|
@ -1032,7 +997,7 @@ export async function runContextBuild(
|
|||
let result: KtxPublicIngestTargetResult | null = null;
|
||||
let thrownError: unknown = null;
|
||||
try {
|
||||
result = await execTarget(targetState.target, runArgs, capture.io, progressDeps);
|
||||
result = await execTarget(targetState.target, runArgs, capture.io, progressDeps, project);
|
||||
} catch (error) {
|
||||
thrownError = error;
|
||||
}
|
||||
|
|
|
|||
39
packages/cli/src/context/core/abort.ts
Normal file
39
packages/cli/src/context/core/abort.ts
Normal file
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -200,27 +200,78 @@ export class BigQueryHistoricSqlQueryHistoryReader {
|
|||
config: HistoricSqlUnifiedPullConfig,
|
||||
): AsyncIterable<AggregatedTemplate> {
|
||||
const sql = `
|
||||
WITH filtered_jobs AS (
|
||||
SELECT
|
||||
COALESCE(query_info.query_hashes.normalized_literals, TO_HEX(SHA256(query))) AS template_id,
|
||||
query,
|
||||
user_email,
|
||||
creation_time,
|
||||
end_time,
|
||||
error_result
|
||||
FROM ${this.viewPath}
|
||||
WHERE job_type = 'QUERY'
|
||||
AND statement_type IN ('SELECT', 'MERGE')
|
||||
AND creation_time >= ${timestampExpression(window.start)}
|
||||
AND creation_time < ${timestampExpression(window.end)}
|
||||
AND query IS NOT NULL
|
||||
),
|
||||
template_stats AS (
|
||||
SELECT
|
||||
template_id,
|
||||
MIN(query) AS canonical_sql,
|
||||
COUNT(*) AS executions,
|
||||
COUNT(DISTINCT user_email) AS distinct_users,
|
||||
MIN(creation_time) AS first_seen,
|
||||
MAX(creation_time) AS last_seen,
|
||||
APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(50)] AS p50_ms,
|
||||
APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(95)] AS p95_ms,
|
||||
SAFE_DIVIDE(COUNTIF(error_result IS NOT NULL), COUNT(*)) AS error_rate,
|
||||
CAST(NULL AS INT64) AS rows_produced
|
||||
FROM filtered_jobs
|
||||
GROUP BY template_id
|
||||
HAVING COUNT(*) >= ${config.minExecutions}
|
||||
),
|
||||
template_users AS (
|
||||
SELECT
|
||||
template_id,
|
||||
user_email AS user,
|
||||
COUNT(*) AS executions,
|
||||
MAX(creation_time) AS last_seen
|
||||
FROM filtered_jobs
|
||||
GROUP BY template_id, user_email
|
||||
)
|
||||
SELECT
|
||||
query_hash AS template_id,
|
||||
MIN(query) AS canonical_sql,
|
||||
COUNT(*) AS executions,
|
||||
COUNT(DISTINCT user_email) AS distinct_users,
|
||||
MIN(creation_time) AS first_seen,
|
||||
MAX(creation_time) AS last_seen,
|
||||
APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(50)] AS p50_ms,
|
||||
APPROX_QUANTILES(TIMESTAMP_DIFF(end_time, creation_time, MILLISECOND), 100)[OFFSET(95)] AS p95_ms,
|
||||
SAFE_DIVIDE(COUNTIF(error_result IS NOT NULL), COUNT(*)) AS error_rate,
|
||||
CAST(NULL AS INT64) AS rows_produced,
|
||||
TO_JSON_STRING(ARRAY_AGG(STRUCT(user_email AS user, 1 AS executions) ORDER BY creation_time DESC LIMIT 5)) AS top_users
|
||||
FROM ${this.viewPath}
|
||||
WHERE job_type = 'QUERY'
|
||||
AND statement_type IN ('SELECT', 'MERGE')
|
||||
AND creation_time >= ${timestampExpression(window.start)}
|
||||
AND creation_time < ${timestampExpression(window.end)}
|
||||
AND query IS NOT NULL
|
||||
GROUP BY query_hash
|
||||
HAVING COUNT(*) >= ${config.minExecutions}
|
||||
ORDER BY executions DESC`.trim();
|
||||
stats.template_id,
|
||||
stats.canonical_sql,
|
||||
stats.executions,
|
||||
stats.distinct_users,
|
||||
stats.first_seen,
|
||||
stats.last_seen,
|
||||
stats.p50_ms,
|
||||
stats.p95_ms,
|
||||
stats.error_rate,
|
||||
stats.rows_produced,
|
||||
TO_JSON_STRING(
|
||||
ARRAY_AGG(
|
||||
STRUCT(users.user AS user, users.executions AS executions)
|
||||
ORDER BY users.executions DESC, users.last_seen DESC
|
||||
)
|
||||
) AS top_users
|
||||
FROM template_stats AS stats
|
||||
JOIN template_users AS users
|
||||
ON users.template_id = stats.template_id
|
||||
GROUP BY
|
||||
stats.template_id,
|
||||
stats.canonical_sql,
|
||||
stats.executions,
|
||||
stats.distinct_users,
|
||||
stats.first_seen,
|
||||
stats.last_seen,
|
||||
stats.p50_ms,
|
||||
stats.p95_ms,
|
||||
stats.error_rate,
|
||||
stats.rows_produced
|
||||
ORDER BY stats.executions DESC`.trim();
|
||||
const result = await queryClient(client).executeQuery(sql);
|
||||
if (result.error) {
|
||||
throw grantsError(result.error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
import { tableRefKey } from '../../../scan/table-ref.js';
|
||||
import type { ChunkResult, DiffSet, ScopeDescriptor, WorkUnit } from '../../types.js';
|
||||
import { isHistoricSqlPatternInputShardPath } from './pattern-inputs.js';
|
||||
import { stagedManifestSchema, stagedPatternsInputSchema, stagedTableInputSchema } from './types.js';
|
||||
|
|
@ -37,7 +38,7 @@ export async function chunkHistoricSqlUnifiedStagedDir(stagedDir: string, diffSe
|
|||
}
|
||||
const table = stagedTableInputSchema.parse(await readJson(stagedDir, path));
|
||||
workUnits.push({
|
||||
unitKey: `historic-sql-table-${safeUnitKey(table.table)}`,
|
||||
unitKey: `historic-sql-table-${safeUnitKey(tableRefKey(table.tableRef))}`,
|
||||
displayLabel: `Historic SQL usage: ${table.table}`,
|
||||
rawFiles: [path],
|
||||
dependencyPaths: ['manifest.json'],
|
||||
|
|
|
|||
|
|
@ -26,6 +26,21 @@ export function isQueryHistoryEnabled(connection: unknown): boolean {
|
|||
return queryHistoryRecord(connection)?.enabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the query-history dialect from the connection's driver capability
|
||||
* alone, ignoring whether query history is enabled in ktx.yaml. Use this on the
|
||||
* adapter-registration path when query history has been explicitly requested
|
||||
* for the run (e.g. via `--query-history`, which is itself the opt-in): the
|
||||
* persisted `context.queryHistory.enabled` flag must not gate registration.
|
||||
* Returns null when the connection's driver has no query-history reader.
|
||||
*/
|
||||
export function historicSqlDialectForConnectionDriver(connection: unknown): HistoricSqlDialect | null {
|
||||
const conn = recordOrNull(connection);
|
||||
const driver = String(conn?.driver ?? '').toLowerCase();
|
||||
const registration = getDriverRegistration(driver);
|
||||
return registration?.hasHistoricSqlReader ? historicSqlDialectForDriver(registration.driver) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the query-history dialect for a connection. Returns null when
|
||||
* query history is disabled, or when the connection's driver has no
|
||||
|
|
@ -35,8 +50,5 @@ export function queryHistoryDialectForConnection(connection: unknown): HistoricS
|
|||
if (!isQueryHistoryEnabled(connection)) {
|
||||
return null;
|
||||
}
|
||||
const conn = recordOrNull(connection);
|
||||
const driver = String(conn?.driver ?? '').toLowerCase();
|
||||
const registration = getDriverRegistration(driver);
|
||||
return registration?.hasHistoricSqlReader ? historicSqlDialectForDriver(registration.driver) : null;
|
||||
return historicSqlDialectForConnectionDriver(connection);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Buffer } from 'node:buffer';
|
||||
import { tableRefKey } from '../../../scan/table-ref.js';
|
||||
import type { StagedPatternsInput } from './types.js';
|
||||
|
||||
const HISTORIC_SQL_PATTERN_WORKUNIT_DIR = 'patterns-input';
|
||||
|
|
@ -44,11 +45,16 @@ function sortedAuditTemplates(templates: readonly PatternTemplate[]): PatternTem
|
|||
function sortedPatternCandidates(templates: readonly PatternTemplate[]): PatternTemplate[] {
|
||||
return [...templates]
|
||||
.filter((template) => template.tablesTouched.length >= 2)
|
||||
.map((template) => ({ ...template, tablesTouched: [...template.tablesTouched].sort() }))
|
||||
.map((template) => ({
|
||||
...template,
|
||||
tablesTouched: [...template.tablesTouched].sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right))),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const cardinality = right.tablesTouched.length - left.tablesTouched.length;
|
||||
if (cardinality !== 0) return cardinality;
|
||||
const tableSignature = left.tablesTouched.join('\0').localeCompare(right.tablesTouched.join('\0'));
|
||||
const leftSignature = left.tablesTouched.map(tableRefKey).join('\0');
|
||||
const rightSignature = right.tablesTouched.map(tableRefKey).join('\0');
|
||||
const tableSignature = leftSignature.localeCompare(rightSignature);
|
||||
if (tableSignature !== 0) return tableSignature;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
import { z } from 'zod';
|
||||
import type { KtxLlmRuntimePort } from '../../../../context/llm/runtime-port.js';
|
||||
import type { SqlAnalysisPort } from '../../../../context/sql-analysis/ports.js';
|
||||
import { tableRefKey } from '../../../scan/table-ref.js';
|
||||
import type { KtxTableRef } from '../../../scan/types.js';
|
||||
import { bucketDistinctUsers, bucketExecutions, bucketRecency } from './buckets.js';
|
||||
import {
|
||||
compileHistoricSqlRedactionPatterns,
|
||||
redactHistoricSqlText,
|
||||
type HistoricSqlRedactionPattern,
|
||||
} from './redaction.js';
|
||||
import { includedQueryHistoryTableRefs } from './scope-membership.js';
|
||||
import {
|
||||
aggregatedTemplateSchema,
|
||||
historicSqlUnifiedPullConfigSchema,
|
||||
type AggregatedTemplate,
|
||||
type HistoricSqlDialect,
|
||||
type HistoricSqlReader,
|
||||
} from './types.js';
|
||||
|
||||
export interface QueryHistoryFilterProposal {
|
||||
excludedRoles: Array<{ role: string; reason: string; pattern: string }>;
|
||||
consideredRoleCount: number;
|
||||
skipped: { reason: 'no-llm' | 'no-daemon' | 'no-in-scope-history' | 'user-block-present' } | null;
|
||||
warnings: string[];
|
||||
parseFailedTemplateIds: string[];
|
||||
}
|
||||
|
||||
export interface ProposeQueryHistoryServiceAccountFiltersInput {
|
||||
connectionId: string;
|
||||
dialect: HistoricSqlDialect;
|
||||
queryClient: unknown;
|
||||
reader: HistoricSqlReader;
|
||||
sqlAnalysis: SqlAnalysisPort;
|
||||
llmRuntime: KtxLlmRuntimePort | null;
|
||||
pullConfig: unknown;
|
||||
now?: Date;
|
||||
userServiceAccountsPresent?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedTemplateForPicker {
|
||||
template: AggregatedTemplate;
|
||||
tablesTouched: KtxTableRef[];
|
||||
includedTables: KtxTableRef[];
|
||||
}
|
||||
|
||||
interface RoleAccumulator {
|
||||
role: string;
|
||||
executions: number;
|
||||
distinctUsers: number;
|
||||
lastSeen: string;
|
||||
tables: Map<string, KtxTableRef>;
|
||||
templates: AggregatedTemplate[];
|
||||
}
|
||||
|
||||
interface QueryHistoryRoleRecord {
|
||||
role: string;
|
||||
inScopeTables: string[];
|
||||
executionsBucket: string;
|
||||
distinctUsersBucket: string;
|
||||
recencyBucket: string;
|
||||
representativeTemplates: Array<{ id: string; canonicalSql: string; dialect: HistoricSqlDialect }>;
|
||||
}
|
||||
|
||||
const queryHistoryFilterAdjudicationSchema = z.object({
|
||||
roles: z.array(
|
||||
z.object({
|
||||
role: z.string().min(1),
|
||||
exclude: z.boolean(),
|
||||
reason: z.string().min(1),
|
||||
}).strict(),
|
||||
),
|
||||
}).strict();
|
||||
|
||||
type QueryHistoryFilterAdjudication = z.infer<typeof queryHistoryFilterAdjudicationSchema>;
|
||||
|
||||
function emptyProposal(skipped: QueryHistoryFilterProposal['skipped'], warnings: string[] = []): QueryHistoryFilterProposal {
|
||||
return { excludedRoles: [], consideredRoleCount: 0, skipped, warnings, parseFailedTemplateIds: [] };
|
||||
}
|
||||
|
||||
function displayTableRef(ref: KtxTableRef): string {
|
||||
return [ref.catalog, ref.db, ref.name].filter((part): part is string => !!part && part.length > 0).join('.');
|
||||
}
|
||||
|
||||
function redactTemplateSqlForPicker(
|
||||
template: AggregatedTemplate,
|
||||
redactors: readonly HistoricSqlRedactionPattern[],
|
||||
): AggregatedTemplate {
|
||||
if (redactors.length === 0) {
|
||||
return template;
|
||||
}
|
||||
return {
|
||||
...template,
|
||||
canonicalSql: redactHistoricSqlText(template.canonicalSql, redactors),
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function regexEscapeForExactRolePattern(role: string): string {
|
||||
return `^${role.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')}$`;
|
||||
}
|
||||
|
||||
function recordRole(
|
||||
acc: RoleAccumulator,
|
||||
template: AggregatedTemplate,
|
||||
tables: readonly KtxTableRef[],
|
||||
executions: number,
|
||||
): void {
|
||||
acc.executions += executions;
|
||||
acc.distinctUsers = Math.max(acc.distinctUsers, template.stats.distinctUsers);
|
||||
acc.lastSeen = template.stats.lastSeen > acc.lastSeen ? template.stats.lastSeen : acc.lastSeen;
|
||||
for (const table of tables) {
|
||||
acc.tables.set(tableRefKey(table), table);
|
||||
}
|
||||
acc.templates.push(template);
|
||||
}
|
||||
|
||||
function roleRecords(parsedTemplates: readonly ParsedTemplateForPicker[], now: Date): QueryHistoryRoleRecord[] {
|
||||
const byRole = new Map<string, RoleAccumulator>();
|
||||
for (const parsed of parsedTemplates) {
|
||||
for (const entry of parsed.template.topUsers) {
|
||||
if (!entry.user || entry.user.trim().length === 0 || entry.executions <= 0) {
|
||||
continue;
|
||||
}
|
||||
const role = entry.user.trim();
|
||||
const acc =
|
||||
byRole.get(role) ??
|
||||
{
|
||||
role,
|
||||
executions: 0,
|
||||
distinctUsers: 0,
|
||||
lastSeen: '1970-01-01T00:00:00.000Z',
|
||||
tables: new Map<string, KtxTableRef>(),
|
||||
templates: [],
|
||||
};
|
||||
recordRole(acc, parsed.template, parsed.includedTables, entry.executions);
|
||||
byRole.set(role, acc);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byRole.values()]
|
||||
.sort((left, right) => right.executions - left.executions || left.role.localeCompare(right.role))
|
||||
.map((acc) => ({
|
||||
role: acc.role,
|
||||
inScopeTables: [...acc.tables.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.slice(0, 25)
|
||||
.map(([, ref]) => displayTableRef(ref)),
|
||||
executionsBucket: bucketExecutions(acc.executions),
|
||||
distinctUsersBucket: bucketDistinctUsers(acc.distinctUsers),
|
||||
recencyBucket: bucketRecency(acc.lastSeen, now),
|
||||
representativeTemplates: [...acc.templates]
|
||||
.sort((left, right) => right.stats.executions - left.stats.executions || left.templateId.localeCompare(right.templateId))
|
||||
.slice(0, 3)
|
||||
.map((template) => ({
|
||||
id: template.templateId,
|
||||
canonicalSql: template.canonicalSql,
|
||||
dialect: template.dialect,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function adjudicationSystemPrompt(): string {
|
||||
return [
|
||||
'You are helping ktx decide whether observed query-history roles are operational service accounts.',
|
||||
'Default every role to keep. Mark exclude true only when the aggregate evidence clearly shows loader, ELT, reverse-ETL, export, refresh, or maintenance traffic rather than analyst or BI-dashboard usage.',
|
||||
'Use only the observed role records. Do not rely on a hardcoded denylist. Return structured output only.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function proposeQueryHistoryServiceAccountFilters(
|
||||
input: ProposeQueryHistoryServiceAccountFiltersInput,
|
||||
): Promise<QueryHistoryFilterProposal> {
|
||||
if (!input.llmRuntime) {
|
||||
return emptyProposal({ reason: 'no-llm' });
|
||||
}
|
||||
|
||||
const config = historicSqlUnifiedPullConfigSchema.parse(input.pullConfig);
|
||||
const redactors = compileHistoricSqlRedactionPatterns(config.redactionPatterns);
|
||||
const now = input.now ?? new Date();
|
||||
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 {
|
||||
for await (const row of input.reader.fetchAggregated(input.queryClient, { start: windowStart, end: now }, config)) {
|
||||
snapshot.push(aggregatedTemplateSchema.parse(row));
|
||||
}
|
||||
} catch (error) {
|
||||
return emptyProposal(null, [
|
||||
`query_history_filter_picker_read_failed:${error instanceof Error ? error.message : String(error)}`,
|
||||
]);
|
||||
}
|
||||
|
||||
if (snapshot.length === 0) {
|
||||
return emptyProposal({ reason: 'no-in-scope-history' });
|
||||
}
|
||||
|
||||
const analysisItems = snapshot.map((template) => ({ id: template.templateId, sql: template.canonicalSql }));
|
||||
const analysisOptions =
|
||||
config.modeledTableCatalog.length > 0 ? { catalog: { tables: config.modeledTableCatalog } } : undefined;
|
||||
let analysis: Awaited<ReturnType<SqlAnalysisPort['analyzeBatch']>>;
|
||||
try {
|
||||
analysis = await input.sqlAnalysis.analyzeBatch(analysisItems, input.dialect, analysisOptions);
|
||||
} catch (error) {
|
||||
return emptyProposal({ reason: 'no-daemon' }, [
|
||||
`query_history_filter_picker_analysis_failed:${error instanceof Error ? error.message : String(error)}`,
|
||||
]);
|
||||
}
|
||||
|
||||
const parsedTemplates: ParsedTemplateForPicker[] = [];
|
||||
for (const template of snapshot) {
|
||||
const parsed = analysis.get(template.templateId);
|
||||
if (!parsed || parsed.error) {
|
||||
parseFailedTemplateIds.push(template.templateId);
|
||||
continue;
|
||||
}
|
||||
const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()]
|
||||
.filter((ref) => ref.name.length > 0)
|
||||
.sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right)));
|
||||
const includedTables = includedQueryHistoryTableRefs(tablesTouched, config);
|
||||
if (includedTables.length === 0) {
|
||||
continue;
|
||||
}
|
||||
parsedTemplates.push({
|
||||
template: redactTemplateSqlForPicker(template, redactors),
|
||||
tablesTouched,
|
||||
includedTables,
|
||||
});
|
||||
}
|
||||
|
||||
const records = roleRecords(parsedTemplates, now);
|
||||
if (records.length <= 1) {
|
||||
return {
|
||||
excludedRoles: [],
|
||||
consideredRoleCount: records.length,
|
||||
skipped: { reason: 'no-in-scope-history' },
|
||||
warnings,
|
||||
parseFailedTemplateIds,
|
||||
};
|
||||
}
|
||||
|
||||
let generated: QueryHistoryFilterAdjudication;
|
||||
try {
|
||||
generated = await input.llmRuntime.generateObject<QueryHistoryFilterAdjudication, typeof queryHistoryFilterAdjudicationSchema>({
|
||||
role: 'candidateExtraction',
|
||||
system: adjudicationSystemPrompt(),
|
||||
prompt: JSON.stringify({ connectionId: input.connectionId, dialect: input.dialect, roles: records }),
|
||||
schema: queryHistoryFilterAdjudicationSchema,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
excludedRoles: [],
|
||||
consideredRoleCount: records.length,
|
||||
skipped: { reason: 'no-llm' },
|
||||
warnings: [
|
||||
...warnings,
|
||||
`query_history_filter_picker_llm_failed:${error instanceof Error ? error.message : String(error)}`,
|
||||
],
|
||||
parseFailedTemplateIds,
|
||||
};
|
||||
}
|
||||
|
||||
const knownRoles = new Set(records.map((record) => record.role));
|
||||
const excludedRoles = generated.roles
|
||||
.filter((role) => role.exclude && knownRoles.has(role.role))
|
||||
.sort((left, right) => left.role.localeCompare(right.role))
|
||||
.map((role) => ({
|
||||
role: role.role,
|
||||
reason: role.reason,
|
||||
pattern: regexEscapeForExactRolePattern(role.role),
|
||||
}));
|
||||
|
||||
return {
|
||||
excludedRoles,
|
||||
consideredRoleCount: records.length,
|
||||
skipped: input.userServiceAccountsPresent ? { reason: 'user-block-present' } : null,
|
||||
warnings,
|
||||
parseFailedTemplateIds,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import type { Dirent } from 'node:fs';
|
||||
import { access, readdir, readFile } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { getDriverRegistration } from '../../../connections/drivers.js';
|
||||
import { parseDottedTableEntry } from '../../../scan/enabled-tables.js';
|
||||
import { tableRefKey, tableRefSet, type KtxTableRefKey } from '../../../scan/table-ref.js';
|
||||
import type { KtxTableRef } from '../../../scan/types.js';
|
||||
import { readLiveDatabaseTableFiles } from '../live-database/stage.js';
|
||||
|
||||
export interface QueryHistoryScopeFloorInput {
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
driver: string;
|
||||
connection: Record<string, unknown>;
|
||||
storedQueryHistory: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QueryHistoryScopeFloor {
|
||||
enabledTables: KtxTableRef[];
|
||||
enabledTableKeys: ReadonlySet<KtxTableRefKey> | null;
|
||||
enabledSchemas: string[];
|
||||
modeledTableCatalog: KtxTableRef[];
|
||||
floorDisabled: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
function tableRefsFromValues(values: unknown): KtxTableRef[] {
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values.flatMap((value) => {
|
||||
if (typeof value === 'string') {
|
||||
const ref = parseDottedTableEntry(value);
|
||||
return ref ? [ref] : [];
|
||||
}
|
||||
if (isRecord(value) && typeof value.name === 'string' && value.name.length > 0) {
|
||||
return [
|
||||
{
|
||||
catalog: typeof value.catalog === 'string' ? value.catalog : null,
|
||||
db: typeof value.db === 'string' ? value.db : null,
|
||||
name: value.name,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function declaredSchemas(driver: string, connection: Record<string, unknown>): string[] {
|
||||
const key = getDriverRegistration(driver)?.scopeConfigKey;
|
||||
if (!key) return [];
|
||||
return [...new Set(stringArray(connection[key]))].sort();
|
||||
}
|
||||
|
||||
function uniqueSortedTableRefs(refs: readonly KtxTableRef[]): KtxTableRef[] {
|
||||
const byKey = new Map<KtxTableRefKey, KtxTableRef>();
|
||||
for (const ref of refs) {
|
||||
byKey.set(tableRefKey(ref), ref);
|
||||
}
|
||||
return [...byKey.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([, ref]) => ref);
|
||||
}
|
||||
|
||||
async function latestLiveDatabaseScanDir(projectDir: string, connectionId: string): Promise<string | null> {
|
||||
const root = join(projectDir, 'raw-sources', connectionId, 'live-database');
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
const syncDirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const syncDir of syncDirs) {
|
||||
const absolute = join(root, syncDir);
|
||||
try {
|
||||
await access(join(absolute, 'connection.json'));
|
||||
return absolute;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function scannedTableRefs(
|
||||
projectDir: string,
|
||||
connectionId: string,
|
||||
): Promise<{ refs: KtxTableRef[]; catalogAvailable: boolean; warnings: string[] }> {
|
||||
const scanDir = await latestLiveDatabaseScanDir(projectDir, connectionId);
|
||||
if (!scanDir) {
|
||||
return { refs: [], catalogAvailable: false, warnings: [] };
|
||||
}
|
||||
try {
|
||||
const tableFiles = await readLiveDatabaseTableFiles(scanDir);
|
||||
return {
|
||||
refs: uniqueSortedTableRefs(
|
||||
tableFiles.map(({ table }) => ({ catalog: table.catalog, db: table.db, name: table.name })),
|
||||
),
|
||||
catalogAvailable: true,
|
||||
warnings: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
refs: [],
|
||||
catalogAvailable: false,
|
||||
warnings: [
|
||||
`query_history_scope_floor_catalog_read_failed:live_database_scan:${error instanceof Error ? error.message : String(error)}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function listYamlFiles(root: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(root, { withFileTypes: true, recursive: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name))
|
||||
.map((entry) => relative(root, join(entry.parentPath, entry.name)).replace(/\\/g, '/'))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function refsFromManifest(content: string): KtxTableRef[] {
|
||||
const parsed = YAML.parse(content) as unknown;
|
||||
if (!isRecord(parsed) || !isRecord(parsed.tables)) return [];
|
||||
return Object.values(parsed.tables).flatMap((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.table !== 'string') return [];
|
||||
const ref = parseDottedTableEntry(entry.table);
|
||||
return ref ? [ref] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function refsFromStandaloneSource(content: string): KtxTableRef[] {
|
||||
const parsed = YAML.parse(content) as unknown;
|
||||
if (!isRecord(parsed) || typeof parsed.table !== 'string') return [];
|
||||
const ref = parseDottedTableEntry(parsed.table);
|
||||
return ref ? [ref] : [];
|
||||
}
|
||||
|
||||
async function semanticTableRefs(
|
||||
projectDir: string,
|
||||
connectionId: string,
|
||||
): Promise<{ refs: KtxTableRef[]; warnings: string[] }> {
|
||||
const root = join(projectDir, 'semantic-layer', connectionId);
|
||||
const files = await listYamlFiles(root);
|
||||
const refs: KtxTableRef[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await readFile(join(root, file), 'utf-8');
|
||||
refs.push(...(file.startsWith('_schema/') ? refsFromManifest(content) : refsFromStandaloneSource(content)));
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`query_history_scope_floor_catalog_read_failed:${file}:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { refs: uniqueSortedTableRefs(refs), warnings };
|
||||
}
|
||||
|
||||
export async function resolveQueryHistoryScopeFloor(input: QueryHistoryScopeFloorInput): Promise<QueryHistoryScopeFloor> {
|
||||
const explicitEnabledTables = [
|
||||
...tableRefsFromValues(input.storedQueryHistory.enabledTables),
|
||||
...tableRefsFromValues(input.connection.enabled_tables),
|
||||
];
|
||||
const semanticTables = await semanticTableRefs(input.projectDir, input.connectionId);
|
||||
const scannedTables = await scannedTableRefs(input.projectDir, input.connectionId);
|
||||
const modeledTables = uniqueSortedTableRefs([
|
||||
...semanticTables.refs,
|
||||
...scannedTables.refs,
|
||||
...explicitEnabledTables,
|
||||
]);
|
||||
const warnings = [...semanticTables.warnings, ...scannedTables.warnings];
|
||||
|
||||
if (explicitEnabledTables.length > 0) {
|
||||
return {
|
||||
enabledTables: explicitEnabledTables,
|
||||
enabledTableKeys: tableRefSet(explicitEnabledTables),
|
||||
enabledSchemas: [],
|
||||
modeledTableCatalog: modeledTables,
|
||||
floorDisabled: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const explicitSchemas = stringArray(input.storedQueryHistory.enabledSchemas);
|
||||
if (explicitSchemas.includes('*')) {
|
||||
return {
|
||||
enabledTables: [],
|
||||
enabledTableKeys: null,
|
||||
enabledSchemas: ['*'],
|
||||
modeledTableCatalog: modeledTables,
|
||||
floorDisabled: true,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (explicitSchemas.length > 0) {
|
||||
if (!scannedTables.catalogAvailable || modeledTables.length === 0) {
|
||||
return {
|
||||
enabledTables: [],
|
||||
enabledTableKeys: null,
|
||||
enabledSchemas: ['*'],
|
||||
modeledTableCatalog: modeledTables,
|
||||
floorDisabled: true,
|
||||
warnings: [...warnings, 'query_history_scope_floor_disabled:catalog_unavailable'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabledTables: [],
|
||||
enabledTableKeys: null,
|
||||
enabledSchemas: [...new Set(explicitSchemas)].sort(),
|
||||
modeledTableCatalog: modeledTables,
|
||||
floorDisabled: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const schemas = new Set(declaredSchemas(input.driver, input.connection));
|
||||
for (const ref of semanticTables.refs) {
|
||||
if (ref.db) schemas.add(ref.db);
|
||||
}
|
||||
if (schemas.size > 0 && (!scannedTables.catalogAvailable || modeledTables.length === 0)) {
|
||||
return {
|
||||
enabledTables: [],
|
||||
enabledTableKeys: null,
|
||||
enabledSchemas: ['*'],
|
||||
modeledTableCatalog: modeledTables,
|
||||
floorDisabled: true,
|
||||
warnings: [...warnings, 'query_history_scope_floor_disabled:catalog_unavailable'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabledTables: [],
|
||||
enabledTableKeys: null,
|
||||
enabledSchemas: [...schemas].sort(),
|
||||
modeledTableCatalog: modeledTables,
|
||||
floorDisabled: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { tableRefKey, tableRefSet } from '../../../scan/table-ref.js';
|
||||
import type { KtxTableRef } from '../../../scan/types.js';
|
||||
|
||||
export interface QueryHistoryScopeMembershipConfig {
|
||||
enabledTables: readonly KtxTableRef[];
|
||||
enabledSchemas: readonly string[];
|
||||
}
|
||||
|
||||
function schemaNameForRef(ref: KtxTableRef): string | null {
|
||||
return ref.db && ref.db.length > 0 ? ref.db : null;
|
||||
}
|
||||
|
||||
function schemaNamesFromConfig(enabledSchemas: readonly string[]): Set<string> {
|
||||
return new Set(enabledSchemas.filter((schema) => schema !== '*'));
|
||||
}
|
||||
|
||||
export function isQueryHistoryScopeFloorDisabled(config: QueryHistoryScopeMembershipConfig): boolean {
|
||||
return config.enabledSchemas.includes('*');
|
||||
}
|
||||
|
||||
export function shouldFailOpenQueryHistoryScope(config: QueryHistoryScopeMembershipConfig): boolean {
|
||||
return (
|
||||
config.enabledTables.length === 0 &&
|
||||
!isQueryHistoryScopeFloorDisabled(config) &&
|
||||
config.enabledSchemas.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function includedQueryHistoryTableRefs(
|
||||
tablesTouched: readonly KtxTableRef[],
|
||||
config: QueryHistoryScopeMembershipConfig,
|
||||
): KtxTableRef[] {
|
||||
if (config.enabledTables.length > 0) {
|
||||
const enabled = tableRefSet(config.enabledTables);
|
||||
return tablesTouched.filter((ref) => enabled.has(tableRefKey(ref)));
|
||||
}
|
||||
if (isQueryHistoryScopeFloorDisabled(config) || shouldFailOpenQueryHistoryScope(config)) {
|
||||
return [...tablesTouched];
|
||||
}
|
||||
const schemas = schemaNamesFromConfig(config.enabledSchemas);
|
||||
return tablesTouched.filter((ref) => {
|
||||
const schema = schemaNameForRef(ref);
|
||||
return schema !== null && schemas.has(schema);
|
||||
});
|
||||
}
|
||||
|
|
@ -188,26 +188,75 @@ export class SnowflakeHistoricSqlQueryHistoryReader {
|
|||
config: HistoricSqlUnifiedPullConfig,
|
||||
): AsyncIterable<AggregatedTemplate> {
|
||||
const sql = `
|
||||
WITH filtered_queries AS (
|
||||
SELECT
|
||||
query_hash,
|
||||
query_text,
|
||||
user_name,
|
||||
start_time,
|
||||
total_elapsed_time,
|
||||
execution_status,
|
||||
rows_produced
|
||||
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
|
||||
WHERE query_text IS NOT NULL
|
||||
AND query_type IN ('SELECT', 'MERGE')
|
||||
AND start_time >= ${timestampLiteral(window.start)}
|
||||
AND start_time < ${timestampLiteral(window.end)}
|
||||
),
|
||||
template_stats AS (
|
||||
SELECT
|
||||
query_hash AS template_id,
|
||||
MIN(query_text) AS canonical_sql,
|
||||
COUNT(*) AS executions,
|
||||
COUNT(DISTINCT user_name) AS distinct_users,
|
||||
MIN(start_time) AS first_seen,
|
||||
MAX(start_time) AS last_seen,
|
||||
APPROX_PERCENTILE(total_elapsed_time, 0.50) AS p50_ms,
|
||||
APPROX_PERCENTILE(total_elapsed_time, 0.95) AS p95_ms,
|
||||
DIV0(COUNT_IF(execution_status != 'SUCCESS'), COUNT(*)) AS error_rate,
|
||||
SUM(rows_produced) AS rows_produced
|
||||
FROM filtered_queries
|
||||
GROUP BY query_hash
|
||||
HAVING COUNT(*) >= ${config.minExecutions}
|
||||
),
|
||||
template_users AS (
|
||||
SELECT
|
||||
query_hash AS template_id,
|
||||
user_name AS user,
|
||||
COUNT(*) AS executions,
|
||||
MAX(start_time) AS last_seen
|
||||
FROM filtered_queries
|
||||
GROUP BY query_hash, user_name
|
||||
)
|
||||
SELECT
|
||||
query_hash AS template_id,
|
||||
MIN(query_text) AS canonical_sql,
|
||||
COUNT(*) AS executions,
|
||||
COUNT(DISTINCT user_name) AS distinct_users,
|
||||
MIN(start_time) AS first_seen,
|
||||
MAX(start_time) AS last_seen,
|
||||
APPROX_PERCENTILE(total_elapsed_time, 0.50) AS p50_ms,
|
||||
APPROX_PERCENTILE(total_elapsed_time, 0.95) AS p95_ms,
|
||||
DIV0(COUNT_IF(execution_status != 'SUCCESS'), COUNT(*)) AS error_rate,
|
||||
SUM(rows_produced) AS rows_produced,
|
||||
ARRAY_AGG(OBJECT_CONSTRUCT('user', user_name, 'executions', 1)) WITHIN GROUP (ORDER BY start_time DESC)::string AS top_users
|
||||
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
|
||||
WHERE query_text IS NOT NULL
|
||||
AND query_type IN ('SELECT', 'MERGE')
|
||||
AND start_time >= ${timestampLiteral(window.start)}
|
||||
AND start_time < ${timestampLiteral(window.end)}
|
||||
GROUP BY query_hash
|
||||
HAVING COUNT(*) >= ${config.minExecutions}
|
||||
ORDER BY executions DESC`.trim();
|
||||
stats.template_id,
|
||||
stats.canonical_sql,
|
||||
stats.executions,
|
||||
stats.distinct_users,
|
||||
stats.first_seen,
|
||||
stats.last_seen,
|
||||
stats.p50_ms,
|
||||
stats.p95_ms,
|
||||
stats.error_rate,
|
||||
stats.rows_produced,
|
||||
ARRAY_AGG(
|
||||
OBJECT_CONSTRUCT('user', users.user, 'executions', users.executions)
|
||||
) WITHIN GROUP (ORDER BY users.executions DESC, users.last_seen DESC)::string AS top_users
|
||||
FROM template_stats AS stats
|
||||
JOIN template_users AS users
|
||||
ON users.template_id = stats.template_id
|
||||
GROUP BY
|
||||
stats.template_id,
|
||||
stats.canonical_sql,
|
||||
stats.executions,
|
||||
stats.distinct_users,
|
||||
stats.first_seen,
|
||||
stats.last_seen,
|
||||
stats.p50_ms,
|
||||
stats.p95_ms,
|
||||
stats.error_rate,
|
||||
stats.rows_produced
|
||||
ORDER BY stats.executions DESC`.trim();
|
||||
const result = await queryClient(client).executeQuery(sql);
|
||||
if (result.error) {
|
||||
throw grantsError(result.error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { SqlAnalysisPort } from '../../../../context/sql-analysis/ports.js';
|
||||
import { tableRefKey, type KtxTableRefKey } from '../../../scan/table-ref.js';
|
||||
import type { KtxTableRef } from '../../../scan/types.js';
|
||||
import {
|
||||
bucketDistinctUsers,
|
||||
bucketErrorRate,
|
||||
|
|
@ -15,6 +17,11 @@ import {
|
|||
redactHistoricSqlText,
|
||||
type HistoricSqlRedactionPattern,
|
||||
} from './redaction.js';
|
||||
import {
|
||||
includedQueryHistoryTableRefs,
|
||||
isQueryHistoryScopeFloorDisabled,
|
||||
shouldFailOpenQueryHistoryScope,
|
||||
} from './scope-membership.js';
|
||||
import {
|
||||
HISTORIC_SQL_SOURCE_KEY,
|
||||
aggregatedTemplateSchema,
|
||||
|
|
@ -38,17 +45,13 @@ interface StageHistoricSqlAggregatedSnapshotInput {
|
|||
|
||||
interface ParsedTemplate {
|
||||
template: AggregatedTemplate;
|
||||
tablesTouched: string[];
|
||||
includedTables: string[];
|
||||
tablesTouched: KtxTableRef[];
|
||||
includedTables: KtxTableRef[];
|
||||
columnsByClause: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface EnabledTableFilter {
|
||||
exact: Set<string>;
|
||||
uniqueUnqualified: Set<string>;
|
||||
}
|
||||
|
||||
interface TableAccumulator {
|
||||
tableRef: KtxTableRef;
|
||||
table: string;
|
||||
executions: number;
|
||||
distinctUsers: number;
|
||||
|
|
@ -79,8 +82,21 @@ function matchesAny(value: string | null, patterns: RegExp[]): boolean {
|
|||
return !!value && patterns.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
// ktx's own warehouse scan emits relationship- and column-profiling probes that land in
|
||||
// pg_stat_statements (relationship-validation, relationship-composite-candidates, and each
|
||||
// dialect's relationship value aggregation). They are ktx introspection, not genuine query
|
||||
// usage, so they must not be mined back as query history. The markers are ktx-owned
|
||||
// identifiers, stable across dialects.
|
||||
function isKtxScanProbe(sql: string): boolean {
|
||||
if (/\brelationship_profile_values\b/i.test(sql)) {
|
||||
return true;
|
||||
}
|
||||
return /\bchild_values\b/i.test(sql) && /\bparent_values\b/i.test(sql);
|
||||
}
|
||||
|
||||
function shouldDropBySql(sql: string, config: HistoricSqlUnifiedPullConfig): boolean {
|
||||
if (NOISE_PREFIX_RE.test(sql) || SYSTEM_TABLE_RE.test(sql)) return true;
|
||||
if (isKtxScanProbe(sql)) return true;
|
||||
if (config.filters.dropTrivialProbes !== false && TRIVIAL_SQL_RE.test(sql)) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -92,8 +108,7 @@ function shouldDropByUsers(template: AggregatedTemplate, config: HistoricSqlUnif
|
|||
const matchingExecutions = template.topUsers
|
||||
.filter((entry) => matchesAny(entry.user, patterns))
|
||||
.reduce((sum, entry) => sum + entry.executions, 0);
|
||||
const allExecutions = template.topUsers.reduce((sum, entry) => sum + entry.executions, 0);
|
||||
const serviceOnly = allExecutions > 0 && matchingExecutions >= allExecutions;
|
||||
const serviceOnly = template.stats.executions > 0 && matchingExecutions >= template.stats.executions;
|
||||
return service.mode === 'exclude' ? serviceOnly : !serviceOnly;
|
||||
}
|
||||
|
||||
|
|
@ -109,43 +124,8 @@ function shouldDropTemplate(template: AggregatedTemplate, config: HistoricSqlUni
|
|||
return false;
|
||||
}
|
||||
|
||||
function normalizeTableIdentifier(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function unqualifiedTableIdentifier(value: string): string {
|
||||
const parts = normalizeTableIdentifier(value).split('.').filter(Boolean);
|
||||
return parts.at(-1) ?? '';
|
||||
}
|
||||
|
||||
function buildEnabledTableFilter(enabledTables: string[]): EnabledTableFilter | null {
|
||||
if (enabledTables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const exact = new Set(enabledTables.map(normalizeTableIdentifier).filter((value) => value.length > 0));
|
||||
const unqualifiedCounts = new Map<string, number>();
|
||||
for (const table of exact) {
|
||||
const unqualified = unqualifiedTableIdentifier(table);
|
||||
if (unqualified.length > 0) {
|
||||
unqualifiedCounts.set(unqualified, (unqualifiedCounts.get(unqualified) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
exact,
|
||||
uniqueUnqualified: new Set(
|
||||
[...unqualifiedCounts.entries()]
|
||||
.filter(([, count]) => count === 1)
|
||||
.map(([table]) => table),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isEnabledTable(table: string, filter: EnabledTableFilter | null): boolean {
|
||||
if (!filter) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeTableIdentifier(table);
|
||||
return filter.exact.has(normalized) || filter.uniqueUnqualified.has(unqualifiedTableIdentifier(normalized));
|
||||
function displayTableRef(ref: KtxTableRef): string {
|
||||
return [ref.catalog, ref.db, ref.name].filter((part): part is string => !!part && part.length > 0).join('.');
|
||||
}
|
||||
|
||||
function historicSqlWindowDays(config: HistoricSqlUnifiedPullConfig): number {
|
||||
|
|
@ -180,9 +160,10 @@ function recordJoin(acc: TableAccumulator, otherTable: string, columns: string[]
|
|||
}
|
||||
}
|
||||
|
||||
function accumulatorFor(table: string): TableAccumulator {
|
||||
function accumulatorFor(tableRef: KtxTableRef): TableAccumulator {
|
||||
return {
|
||||
table,
|
||||
tableRef,
|
||||
table: displayTableRef(tableRef),
|
||||
executions: 0,
|
||||
distinctUsers: 0,
|
||||
errorRateNumerator: 0,
|
||||
|
|
@ -212,8 +193,8 @@ function addTemplate(acc: TableAccumulator, parsed: ParsedTemplate): void {
|
|||
}
|
||||
}
|
||||
const joinColumns = parsed.columnsByClause.join ?? [];
|
||||
for (const otherTable of parsed.tablesTouched.filter((table) => table !== acc.table)) {
|
||||
recordJoin(acc, otherTable, joinColumns, executions);
|
||||
for (const otherTable of parsed.tablesTouched.filter((table) => tableRefKey(table) !== tableRefKey(acc.tableRef))) {
|
||||
recordJoin(acc, displayTableRef(otherTable), joinColumns, executions);
|
||||
}
|
||||
acc.topTemplates.push(parsed.template);
|
||||
}
|
||||
|
|
@ -250,6 +231,7 @@ function toStagedTable(acc: TableAccumulator, now: Date): StagedTableInput {
|
|||
|
||||
return {
|
||||
table: acc.table,
|
||||
tableRef: acc.tableRef,
|
||||
stats: {
|
||||
executionsBucket: bucketExecutions(acc.executions),
|
||||
distinctUsersBucket: bucketDistinctUsers(acc.distinctUsers),
|
||||
|
|
@ -269,7 +251,7 @@ function toPatternsInput(parsedTemplates: ParsedTemplate[]): StagedPatternsInput
|
|||
.map(({ template, tablesTouched }) => ({
|
||||
id: template.templateId,
|
||||
canonicalSql: template.canonicalSql,
|
||||
tablesTouched: [...tablesTouched].sort(),
|
||||
tablesTouched: [...tablesTouched].sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right))),
|
||||
executionsBucket: bucketExecutions(template.stats.executions),
|
||||
distinctUsersBucket: bucketDistinctUsers(template.stats.distinctUsers),
|
||||
dialect: template.dialect,
|
||||
|
|
@ -280,7 +262,6 @@ function toPatternsInput(parsedTemplates: ParsedTemplate[]): StagedPatternsInput
|
|||
|
||||
export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSqlAggregatedSnapshotInput): Promise<void> {
|
||||
const config = historicSqlUnifiedPullConfigSchema.parse(input.pullConfig);
|
||||
const enabledTableFilter = buildEnabledTableFilter(config.enabledTables);
|
||||
const redactors = compileHistoricSqlRedactionPatterns(config.redactionPatterns);
|
||||
const now = input.now ?? new Date();
|
||||
const windowStart = new Date(now.getTime() - historicSqlWindowDays(config) * 24 * 60 * 60 * 1000);
|
||||
|
|
@ -296,11 +277,25 @@ export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSql
|
|||
}
|
||||
}
|
||||
|
||||
const analysis = await input.sqlAnalysis.analyzeBatch(
|
||||
snapshot.map((template) => ({ id: template.templateId, sql: template.canonicalSql })),
|
||||
config.dialect,
|
||||
);
|
||||
const warnings: string[] = [];
|
||||
const analysisItems = snapshot.map((template) => ({ id: template.templateId, sql: template.canonicalSql }));
|
||||
const analysisOptions =
|
||||
config.modeledTableCatalog.length > 0 ? { catalog: { tables: config.modeledTableCatalog } } : undefined;
|
||||
const warnings: string[] = [
|
||||
...config.scopeFloorWarnings,
|
||||
...(shouldFailOpenQueryHistoryScope(config) ? ['query_history_scope_floor_disabled:empty_modeled_scope'] : []),
|
||||
];
|
||||
let scopeDisabledByQualificationFailure = false;
|
||||
let analysis: Awaited<ReturnType<SqlAnalysisPort['analyzeBatch']>>;
|
||||
try {
|
||||
analysis = await input.sqlAnalysis.analyzeBatch(analysisItems, config.dialect, analysisOptions);
|
||||
} catch (error) {
|
||||
if (!analysisOptions || config.enabledTables.length > 0 || isQueryHistoryScopeFloorDisabled(config)) {
|
||||
throw error;
|
||||
}
|
||||
warnings.push('query_history_scope_floor_disabled:catalog_qualification_failed');
|
||||
scopeDisabledByQualificationFailure = true;
|
||||
analysis = await input.sqlAnalysis.analyzeBatch(analysisItems, config.dialect, undefined);
|
||||
}
|
||||
const parsedTemplates: ParsedTemplate[] = [];
|
||||
for (const template of snapshot) {
|
||||
const parsed = analysis.get(template.templateId);
|
||||
|
|
@ -308,8 +303,12 @@ export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSql
|
|||
warnings.push(`parse_failed:${template.templateId}`);
|
||||
continue;
|
||||
}
|
||||
const tablesTouched = [...new Set(parsed.tablesTouched)].filter((table) => table.length > 0).sort();
|
||||
const includedTables = tablesTouched.filter((table) => isEnabledTable(table, enabledTableFilter));
|
||||
const tablesTouched = [...new Map(parsed.tablesTouched.map((ref) => [tableRefKey(ref), ref])).values()]
|
||||
.filter((ref) => ref.name.length > 0)
|
||||
.sort((left, right) => tableRefKey(left).localeCompare(tableRefKey(right)));
|
||||
const includedTables = scopeDisabledByQualificationFailure
|
||||
? [...tablesTouched]
|
||||
: includedQueryHistoryTableRefs(tablesTouched, config);
|
||||
if (includedTables.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -323,22 +322,23 @@ export async function stageHistoricSqlAggregatedSnapshot(input: StageHistoricSql
|
|||
});
|
||||
}
|
||||
|
||||
const byTable = new Map<string, TableAccumulator>();
|
||||
const byTable = new Map<KtxTableRefKey, TableAccumulator>();
|
||||
for (const parsed of parsedTemplates) {
|
||||
for (const table of parsed.includedTables) {
|
||||
const acc = byTable.get(table) ?? accumulatorFor(table);
|
||||
for (const tableRef of parsed.includedTables) {
|
||||
const key = tableRefKey(tableRef);
|
||||
const acc = byTable.get(key) ?? accumulatorFor(tableRef);
|
||||
addTemplate(acc, parsed);
|
||||
byTable.set(table, acc);
|
||||
byTable.set(key, acc);
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(input.stagedDir, { recursive: true });
|
||||
for (const [table, acc] of [...byTable.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
await writeJson(input.stagedDir, `tables/${table}.json`, toStagedTable(acc, now));
|
||||
for (const [, acc] of [...byTable.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
|
||||
await writeJson(input.stagedDir, `tables/${acc.table}.json`, toStagedTable(acc, now));
|
||||
}
|
||||
const patternsInput = toPatternsInput(parsedTemplates);
|
||||
const patternInputSplit = splitHistoricSqlPatternInputs(patternsInput);
|
||||
const allWarnings = [...warnings, ...patternInputSplit.warnings];
|
||||
const allWarnings = [...new Set([...warnings, ...patternInputSplit.warnings])];
|
||||
await writeJson(input.stagedDir, 'patterns-input.json', patternInputSplit.auditInput);
|
||||
for (const shard of patternInputSplit.shards) {
|
||||
await writeJson(input.stagedDir, shard.path, shard.input);
|
||||
|
|
|
|||
|
|
@ -8,9 +8,22 @@ export type HistoricSqlDialect = z.infer<typeof historicSqlDialectSchema>;
|
|||
|
||||
const filterModeSchema = z.enum(['exclude', 'include', 'mark-only']);
|
||||
|
||||
const ktxTableRefSchema = z.object({
|
||||
catalog: z.string().nullable(),
|
||||
db: z.string().nullable(),
|
||||
name: z.string().min(1),
|
||||
}).strict();
|
||||
|
||||
const ktxTableRefWithColumnsSchema = ktxTableRefSchema.extend({
|
||||
columns: z.array(z.string().min(1)).optional(),
|
||||
}).strict();
|
||||
|
||||
const historicSqlCommonPullConfigSchema = z.object({
|
||||
minExecutions: z.number().int().nonnegative().default(5),
|
||||
enabledTables: z.array(z.string().min(1)).default([]),
|
||||
enabledTables: z.array(ktxTableRefSchema).default([]),
|
||||
enabledSchemas: z.array(z.string().min(1)).default([]),
|
||||
modeledTableCatalog: z.array(ktxTableRefWithColumnsSchema).default([]),
|
||||
scopeFloorWarnings: z.array(z.string()).default([]),
|
||||
filters: z.object({
|
||||
serviceAccounts: z.object({
|
||||
patterns: z.array(z.string()).default([]),
|
||||
|
|
@ -68,6 +81,7 @@ export type AggregatedTemplate = z.infer<typeof aggregatedTemplateSchema>;
|
|||
|
||||
export const stagedTableInputSchema = z.object({
|
||||
table: z.string().min(1),
|
||||
tableRef: ktxTableRefSchema,
|
||||
stats: z.object({
|
||||
executionsBucket: z.string(),
|
||||
distinctUsersBucket: z.string(),
|
||||
|
|
@ -93,7 +107,7 @@ export const stagedPatternsInputSchema = z.object({
|
|||
templates: z.array(z.object({
|
||||
id: z.string(),
|
||||
canonicalSql: z.string(),
|
||||
tablesTouched: z.array(z.string()),
|
||||
tablesTouched: z.array(ktxTableRefSchema),
|
||||
executionsBucket: z.string(),
|
||||
distinctUsersBucket: z.string(),
|
||||
dialect: historicSqlDialectSchema,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IngestBundleResult> {
|
||||
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<RateLimitWaitState, { kind: 'wait_tick' | 'wait_started' | 'wait_finished' }>,
|
||||
): 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<T>(abortSignal: AbortSignal | undefined, fn: () => Promise<T>): Promise<T> {
|
||||
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<boolean>;
|
||||
memoryFlow?: MemoryFlowEventSink;
|
||||
abortSignal?: AbortSignal;
|
||||
wuSkillNames: string[];
|
||||
onStepFinish?: (info: { stepIndex: number; stepBudget: number }) => void;
|
||||
}): Promise<WorkUnitOutcome> {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -155,18 +155,103 @@ export async function integrateWorkUnitPatch(input: IntegrateWorkUnitPatchInput)
|
|||
},
|
||||
);
|
||||
} catch (semanticError) {
|
||||
if (preApplyHead) {
|
||||
await input.integrationGit.resetHardTo(preApplyHead);
|
||||
}
|
||||
const reason = errorMessage(semanticError);
|
||||
await input.trace.event('error', 'integration', 'patch_semantic_conflict_after_textual_resolution', {
|
||||
unitKey: input.unitKey,
|
||||
patchPath: input.patchPath,
|
||||
touchedPaths: textualResolution.changedPaths,
|
||||
reason: errorMessage(semanticError),
|
||||
reason,
|
||||
});
|
||||
|
||||
// A textual conflict and a semantic-gate failure can co-occur: the resolver
|
||||
// reconciles the text but can leave wiki sl_refs pointing at measures the
|
||||
// merged source no longer defines. Recover via the same gate repair the
|
||||
// clean-apply branch uses, instead of hard-failing the whole job.
|
||||
if (input.repairGateFailure) {
|
||||
const gateRepair = await input.repairGateFailure({
|
||||
unitKey: input.unitKey,
|
||||
patchPath: input.patchPath,
|
||||
touchedPaths: textualResolution.changedPaths,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (gateRepair.status !== 'failed') {
|
||||
// The resolver wrote its merge to the worktree (unstaged); the repair
|
||||
// edited a subset on top. Commit the union so neither is dropped.
|
||||
const resolvedAndRepairedPaths = [
|
||||
...new Set([...textualResolution.changedPaths, ...gateRepair.changedPaths]),
|
||||
].sort();
|
||||
try {
|
||||
await traceTimed(
|
||||
input.trace,
|
||||
'integration',
|
||||
'semantic_gate_after_gate_repair',
|
||||
{ unitKey: input.unitKey, touchedPaths: gateRepair.changedPaths },
|
||||
async () => {
|
||||
await input.validateAppliedTree(gateRepair.changedPaths);
|
||||
},
|
||||
);
|
||||
|
||||
const commit = await input.integrationGit.commitFiles(
|
||||
resolvedAndRepairedPaths,
|
||||
`ingest: resolve WorkUnit ${input.unitKey} conflict`,
|
||||
input.author.name,
|
||||
input.author.email,
|
||||
);
|
||||
if (commit.created) {
|
||||
await input.trace.event('debug', 'integration', 'patch_accepted_after_textual_resolution', {
|
||||
unitKey: input.unitKey,
|
||||
commitSha: commit.commitHash,
|
||||
touchedPaths: resolvedAndRepairedPaths,
|
||||
attempts: textualResolution.attempts,
|
||||
gateRepairAttempts: gateRepair.attempts,
|
||||
});
|
||||
return {
|
||||
status: 'accepted',
|
||||
commitSha: commit.commitHash,
|
||||
touchedPaths: resolvedAndRepairedPaths,
|
||||
textualResolution,
|
||||
gateRepair,
|
||||
};
|
||||
}
|
||||
} catch (repairValidationError) {
|
||||
if (preApplyHead) {
|
||||
await input.integrationGit.resetHardTo(preApplyHead);
|
||||
}
|
||||
await input.trace.event('error', 'integration', 'patch_semantic_conflict_after_textual_resolution', {
|
||||
unitKey: input.unitKey,
|
||||
patchPath: input.patchPath,
|
||||
touchedPaths: gateRepair.changedPaths,
|
||||
reason: errorMessage(repairValidationError),
|
||||
});
|
||||
return {
|
||||
status: 'semantic_conflict',
|
||||
reason: errorMessage(repairValidationError),
|
||||
touchedPaths: gateRepair.changedPaths,
|
||||
textualResolution,
|
||||
gateRepair,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (preApplyHead) {
|
||||
await input.integrationGit.resetHardTo(preApplyHead);
|
||||
}
|
||||
return {
|
||||
status: 'semantic_conflict',
|
||||
reason: gateRepair.status === 'failed' ? gateRepair.reason : reason,
|
||||
touchedPaths: textualResolution.changedPaths,
|
||||
textualResolution,
|
||||
gateRepair,
|
||||
};
|
||||
}
|
||||
|
||||
if (preApplyHead) {
|
||||
await input.integrationGit.resetHardTo(preApplyHead);
|
||||
}
|
||||
return {
|
||||
status: 'semantic_conflict',
|
||||
reason: errorMessage(semanticError),
|
||||
reason,
|
||||
touchedPaths: textualResolution.changedPaths,
|
||||
textualResolution,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface RunIsolatedWorkUnitInput {
|
|||
patchDir: string;
|
||||
trace: IngestTraceWriter;
|
||||
workUnit: WorkUnit;
|
||||
abortSignal?: AbortSignal;
|
||||
run(child: IngestSessionWorktree): Promise<WorkUnitOutcome>;
|
||||
afterSuccess?(child: IngestSessionWorktree): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { DbtSourceAdapter } from './adapters/dbt/dbt.adapter.js';
|
|||
import { FakeSourceAdapter } from './adapters/fake/fake.adapter.js';
|
||||
import { HistoricSqlSourceAdapter } from './adapters/historic-sql/historic-sql.adapter.js';
|
||||
import { PostgresPgssReader } from './adapters/historic-sql/postgres-pgss-reader.js';
|
||||
import { resolveQueryHistoryScopeFloor } from './adapters/historic-sql/scope-floor.js';
|
||||
import {
|
||||
HISTORIC_SQL_SOURCE_KEY,
|
||||
historicSqlUnifiedPullConfigSchema,
|
||||
|
|
@ -179,12 +180,39 @@ function queryHistoryRecord(connection: unknown): Record<string, unknown> | null
|
|||
return queryHistory;
|
||||
}
|
||||
|
||||
function queryHistoryPullConfig(connection: unknown): Record<string, unknown> | null {
|
||||
async function queryHistoryPullConfig(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
connection: unknown,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const queryHistory = queryHistoryRecord(connection);
|
||||
if (queryHistory?.enabled !== true || !isRecord(connection)) return null;
|
||||
const dialect = historicSqlDialectByDriver.get(String(connection.driver ?? '').toLowerCase());
|
||||
const driver = String(connection.driver ?? '').toLowerCase();
|
||||
const dialect = historicSqlDialectByDriver.get(driver);
|
||||
if (!dialect) return null;
|
||||
return { ...queryHistory, dialect };
|
||||
const scopeFloor = await resolveQueryHistoryScopeFloor({
|
||||
projectDir: project.projectDir,
|
||||
connectionId,
|
||||
driver,
|
||||
connection,
|
||||
storedQueryHistory: queryHistory,
|
||||
});
|
||||
const {
|
||||
enabled: _enabled,
|
||||
dialect: _dialect,
|
||||
enabledTables: _enabledTables,
|
||||
enabledSchemas: _enabledSchemas,
|
||||
scopeFloorWarnings: _scopeFloorWarnings,
|
||||
...stored
|
||||
} = queryHistory;
|
||||
return {
|
||||
...stored,
|
||||
dialect,
|
||||
...(scopeFloor.enabledTables.length > 0 ? { enabledTables: scopeFloor.enabledTables } : {}),
|
||||
...(scopeFloor.enabledSchemas.length > 0 ? { enabledSchemas: scopeFloor.enabledSchemas } : {}),
|
||||
...(scopeFloor.modeledTableCatalog.length > 0 ? { modeledTableCatalog: scopeFloor.modeledTableCatalog } : {}),
|
||||
...(scopeFloor.warnings.length > 0 ? { scopeFloorWarnings: scopeFloor.warnings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string | null {
|
||||
|
|
@ -245,7 +273,7 @@ export async function localPullConfigForAdapter(
|
|||
if (options.historicSqlPullConfigOverride) {
|
||||
return historicSqlUnifiedPullConfigSchema.parse(options.historicSqlPullConfigOverride);
|
||||
}
|
||||
const queryHistory = queryHistoryPullConfig(connection);
|
||||
const queryHistory = await queryHistoryPullConfig(project, connectionId, connection);
|
||||
if (!queryHistory) {
|
||||
throw new Error(`Connection "${connectionId}" does not have context.queryHistory.enabled: true`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -611,14 +612,15 @@ function nextLocalJobId(): string {
|
|||
|
||||
function localIngestLlmProviderGuardMessage(projectDir: string): string {
|
||||
return [
|
||||
'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.',
|
||||
'Configure a local Claude Code session or API-backed LLM, then rerun ingest:',
|
||||
'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, claude-code, or codex, or an injected agentRunner.',
|
||||
'Configure a local Claude Code/Codex session or API-backed LLM, then rerun ingest:',
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`,
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend codex --llm-model gpt-5.5 --no-input`,
|
||||
` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
|
||||
function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions, rateLimitGovernor: RateLimitGovernor): {
|
||||
agentRunner: AgentRunnerPort;
|
||||
llmRuntime?: KtxLlmRuntimePort;
|
||||
} {
|
||||
|
|
@ -627,6 +629,7 @@ function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): {
|
|||
(options.createLlmRuntime ?? createLocalKtxLlmRuntimeFromConfig)(options.project.config.llm, {
|
||||
projectDir: options.project.projectDir,
|
||||
env: process.env,
|
||||
rateLimitGovernor,
|
||||
}) ??
|
||||
undefined;
|
||||
|
||||
|
|
@ -676,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);
|
||||
|
|
@ -716,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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<LocalIngestResult> {
|
||||
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<Lo
|
|||
queryExecutor: options.queryExecutor,
|
||||
logger: options.logger,
|
||||
embeddingProvider: options.embeddingProvider,
|
||||
abortSignal: options.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +228,7 @@ export async function runLocalIngest(options: RunLocalIngestOptions): Promise<Lo
|
|||
trigger: options.trigger ?? (options.sourceDir ? 'upload' : 'manual_resync'),
|
||||
bundleRef,
|
||||
},
|
||||
localJobContext(jobId, options.memoryFlow),
|
||||
localJobContext(jobId, options.memoryFlow, options.abortSignal),
|
||||
);
|
||||
const report = await runtime.store.findByJobId(jobId);
|
||||
if (!report) {
|
||||
|
|
@ -362,6 +367,9 @@ export async function runLocalMetabaseIngest(
|
|||
|
||||
const children: LocalMetabaseFanoutChild[] = [];
|
||||
for (const childPlan of childPlans) {
|
||||
if (options.abortSignal?.aborted) {
|
||||
throw createAbortError();
|
||||
}
|
||||
const targetConnectionId = safeSegment('target connection id', childPlan.targetConnectionId);
|
||||
if (!options.project.config.connections[targetConnectionId]) {
|
||||
throw new Error(`Target connection "${targetConnectionId}" is not configured in ktx.yaml`);
|
||||
|
|
@ -391,8 +399,12 @@ export async function runLocalMetabaseIngest(
|
|||
queryExecutor: options.queryExecutor,
|
||||
logger: options.logger,
|
||||
embeddingProvider: options.embeddingProvider,
|
||||
abortSignal: options.abortSignal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
child = await recordLocalMetabaseChildFailure({
|
||||
project: options.project,
|
||||
jobId: childJobId,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ const memoryFlowEventSchema = z.discriminatedUnion('type', [
|
|||
message: z.string().min(1),
|
||||
transient: z.boolean().optional(),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('rate_limit_wait'),
|
||||
provider: z.string(),
|
||||
rateLimitType: z.string().optional(),
|
||||
resumeAtMs: z.number().int().nonnegative(),
|
||||
remainingMs: z.number().int().nonnegative(),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('work_unit_started'),
|
||||
unitKey: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ type MemoryFlowEventPayload =
|
|||
message: string;
|
||||
transient?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'rate_limit_wait';
|
||||
provider: string;
|
||||
rateLimitType?: string;
|
||||
resumeAtMs: number;
|
||||
remainingMs: number;
|
||||
}
|
||||
| {
|
||||
type: 'work_unit_started';
|
||||
unitKey: string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { KtxFileStorePort } from '../../context/core/file-store.js';
|
|||
import type { KtxLogger } from '../../context/core/config.js';
|
||||
import type { SessionOutcome } from '../../context/core/session-worktree.service.js';
|
||||
import type { AgentRunnerPort, KtxLlmRuntimePort, KtxRuntimeToolSet } from '../../context/llm/runtime-port.js';
|
||||
import type { RateLimitGovernor } from '../llm/rate-limit-governor.js';
|
||||
import type { MemoryAction, MemoryKnowledgeSlRefsPort } from '../../context/memory/types.js';
|
||||
import type { PromptService } from '../../context/prompts/prompt.service.js';
|
||||
import type { SkillsRegistryService } from '../../context/skills/skills-registry.service.js';
|
||||
|
|
@ -144,6 +145,7 @@ interface IngestSettingsPort {
|
|||
workUnitMaxConcurrency?: number;
|
||||
workUnitStepBudget?: number;
|
||||
workUnitFailureMode?: 'abort' | 'continue';
|
||||
rateLimitGovernor?: RateLimitGovernor;
|
||||
/** Print a timing breakdown to stderr at the end of each run (config-driven; see also KTX_PROFILE_INGEST). `'json'` emits the raw structured profile. */
|
||||
profileIngest?: boolean | 'json';
|
||||
ingestTraceLevel?: IngestTraceLevel;
|
||||
|
|
@ -323,6 +325,7 @@ export interface CuratorPaginationPort {
|
|||
buildToolSet: (passNumber: number) => KtxRuntimeToolSet;
|
||||
getReconciliationActions: () => MemoryAction[];
|
||||
onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<ReconciliationOutcome & { report: CuratorPaginationReport; warnings: string[] }>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,5 +220,6 @@ export interface IngestJobPhase {
|
|||
export interface IngestJobContext {
|
||||
jobId: string;
|
||||
memoryFlow?: MemoryFlowEventSink;
|
||||
abortSignal?: AbortSignal;
|
||||
startPhase(weight: number): IngestJobPhase;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RateLimitGovernor, 'waitForReady' | 'report' | 'maxRetryAttempts'>;
|
||||
}
|
||||
|
||||
function hasTools(tools: Record<string, unknown>): 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<string, string> {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const get = (headers as { get?: unknown }).get;
|
||||
if (typeof get === 'function') {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, unknown>)
|
||||
.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<string, string>, key: string): number | undefined {
|
||||
const value = Number(headers[key]);
|
||||
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function utilizationForPair(headers: Record<string, string>, 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<T>(
|
||||
provider: RateLimitProvider,
|
||||
abortSignal: AbortSignal | undefined,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
// 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<string> {
|
||||
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<TOutput> }),
|
||||
});
|
||||
};
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<typeof defaultQuery>[0]) => AsyncIterable<SDKMessage>;
|
||||
type QueryResult = AsyncIterable<SDKMessage> & {
|
||||
interrupt?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type QueryFn = (params: Parameters<typeof defaultQuery>[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<RateLimitGovernor, 'waitForReady' | 'report' | 'maxRetryAttempts'>;
|
||||
}
|
||||
|
||||
const BUILTIN_TOOLS = [
|
||||
|
|
@ -157,6 +169,74 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set<strin
|
|||
return tools && Object.keys(tools).length > 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<string, unknown>;
|
||||
if (record.type === 'rate_limit_event') {
|
||||
const info = record.rate_limit_info as Record<string, unknown> | 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<Options['managedSettings']> {
|
||||
return {
|
||||
allowManagedMcpServersOnly: true,
|
||||
|
|
@ -217,21 +297,63 @@ async function collectResult(params: {
|
|||
allowedToolIds: Set<string>;
|
||||
expectedMcpServerNames: Set<string>;
|
||||
onAssistantTurn?: () => Promise<void>;
|
||||
}): Promise<SDKResultMessage> {
|
||||
rateLimitGovernor?: Pick<RateLimitGovernor, 'waitForReady' | 'report' | 'maxRetryAttempts'>;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<ClaudeQueryOutcome> {
|
||||
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<typeof collectResult>[0]): Promise<SDKResultMessage> {
|
||||
// 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,
|
||||
|
|
|
|||
194
packages/cli/src/context/llm/codex-exec-events.ts
Normal file
194
packages/cli/src/context/llm/codex-exec-events.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import type { LlmTokenUsage, RunLoopStopReason } from './runtime-port.js';
|
||||
|
||||
export interface CodexExecEventSummary {
|
||||
finalText: string;
|
||||
stopReason: RunLoopStopReason;
|
||||
usage: LlmTokenUsage;
|
||||
stepCount: number;
|
||||
stepBoundariesMs: number[];
|
||||
toolCallCount: number;
|
||||
toolFailures: string[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface CodexEventParseOptions {
|
||||
startedAt?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex thread items that represent a discrete agent action consuming one loop
|
||||
* step. The step budget caps the total number of these regardless of which
|
||||
* capability the agent reaches for, so built-in `command_execution` (and any
|
||||
* file/web action the public Codex surface still exposes) count alongside our
|
||||
* own `mcp_tool_call` items rather than only the MCP ones.
|
||||
*/
|
||||
const AGENT_STEP_ITEM_TYPES = new Set(['command_execution', 'mcp_tool_call', 'file_change', 'web_search']);
|
||||
|
||||
export function isCompletedAgentStep(event: unknown): boolean {
|
||||
const eventRecord = record(event);
|
||||
if (eventRecord?.type !== 'item.completed') {
|
||||
return false;
|
||||
}
|
||||
const itemType = record(eventRecord.item)?.type;
|
||||
return typeof itemType === 'string' && AGENT_STEP_ITEM_TYPES.has(itemType);
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function usageFrom(value: unknown): LlmTokenUsage {
|
||||
const usage = record(value);
|
||||
if (!usage) {
|
||||
return {};
|
||||
}
|
||||
const inputTokens = numberValue(usage.input_tokens ?? usage.inputTokens);
|
||||
const outputTokens = numberValue(usage.output_tokens ?? usage.outputTokens);
|
||||
const explicitTotalTokens = numberValue(usage.total_tokens ?? usage.totalTokens);
|
||||
const totalTokens =
|
||||
explicitTotalTokens ??
|
||||
(inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined);
|
||||
return {
|
||||
...(inputTokens !== undefined ? { inputTokens } : {}),
|
||||
...(outputTokens !== undefined ? { outputTokens } : {}),
|
||||
...(totalTokens !== undefined ? { totalTokens } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stopReasonFrom(value: unknown): RunLoopStopReason {
|
||||
const reason = text(value)?.toLowerCase();
|
||||
if (reason && /(budget|max_turn|max-turn|limit)/.test(reason)) {
|
||||
return 'budget';
|
||||
}
|
||||
return 'natural';
|
||||
}
|
||||
|
||||
function errorMessageFrom(value: unknown): string {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
const asRecord = record(value);
|
||||
const message = text(asRecord?.message);
|
||||
return message ?? text(value) ?? 'Codex turn failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex serializes API failures as a JSON envelope inside the event message
|
||||
* (e.g. `{"type":"error","status":400,"error":{"message":"…"}}`). Surface the
|
||||
* human-readable inner message so callers don't leak raw JSON; pass plain
|
||||
* strings through unchanged.
|
||||
*/
|
||||
function unwrapCodexApiErrorMessage(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return raw;
|
||||
}
|
||||
try {
|
||||
const parsed = record(JSON.parse(trimmed));
|
||||
return text(record(parsed?.error)?.message) ?? text(parsed?.message) ?? raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function parseCodexExecEventLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`Codex JSONL event stream was malformed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeCodexExecEvents(
|
||||
events: Iterable<unknown>,
|
||||
options: CodexEventParseOptions = {},
|
||||
): CodexExecEventSummary {
|
||||
const startedAt = options.startedAt ?? Date.now();
|
||||
const now = options.now ?? Date.now;
|
||||
let finalText = '';
|
||||
let stopReason: RunLoopStopReason = 'natural';
|
||||
let usage: LlmTokenUsage = {};
|
||||
let turnCount = 0;
|
||||
let completedStepCount = 0;
|
||||
const stepBoundariesMs: number[] = [];
|
||||
let toolCallCount = 0;
|
||||
const toolFailures: string[] = [];
|
||||
let error: Error | undefined;
|
||||
|
||||
for (const event of events) {
|
||||
const eventRecord = record(event);
|
||||
const eventType = text(eventRecord?.type);
|
||||
if (!eventRecord || !eventType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === 'turn.started') {
|
||||
turnCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = record(eventRecord.item);
|
||||
const itemType = text(item?.type);
|
||||
|
||||
if (eventType === 'item.started' && itemType === 'mcp_tool_call') {
|
||||
toolCallCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCompletedAgentStep(event)) {
|
||||
completedStepCount += 1;
|
||||
stepBoundariesMs.push(now() - startedAt);
|
||||
// Only MCP tool calls fail the loop: a non-zero `command_execution` exit
|
||||
// is normal agent exploration, not a runtime error. `status` is the
|
||||
// authoritative signal (the SDK always sets it); the SDK also serializes
|
||||
// `error: null` on successful calls, so an explicit-null `error` must NOT
|
||||
// be read as a failure — only a populated error object counts.
|
||||
if (itemType === 'mcp_tool_call' && (item?.status === 'failed' || (item?.error !== undefined && item?.error !== null))) {
|
||||
const name = text(item?.name) ?? text(item?.tool) ?? text(item?.tool_name) ?? 'unknown';
|
||||
toolFailures.push(`${name}: ${errorMessageFrom(item?.error)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === 'item.completed' && itemType === 'agent_message') {
|
||||
finalText = text(item?.text) ?? finalText;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === 'turn.completed') {
|
||||
usage = usageFrom(eventRecord.usage);
|
||||
if (completedStepCount === 0) {
|
||||
stepBoundariesMs.push(now() - startedAt);
|
||||
}
|
||||
stopReason = stopReasonFrom(eventRecord.reason ?? eventRecord.stop_reason ?? eventRecord.terminal_reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === 'turn.failed' || eventType === 'error') {
|
||||
stopReason = 'error';
|
||||
error = new Error(unwrapCodexApiErrorMessage(errorMessageFrom(eventRecord.error ?? eventRecord.message)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
finalText,
|
||||
stopReason,
|
||||
usage,
|
||||
stepCount: completedStepCount > 0 ? completedStepCount : turnCount,
|
||||
stepBoundariesMs,
|
||||
toolCallCount,
|
||||
toolFailures,
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
}
|
||||
9
packages/cli/src/context/llm/codex-isolation.ts
Normal file
9
packages/cli/src/context/llm/codex-isolation.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const CODEX_ISOLATION_WARNING =
|
||||
'Codex backend isolation is limited by the public Codex SDK/CLI surface: ktx restricts the runtime MCP server to the current ktx tool set, disables Codex web search, asks for a read-only sandbox, and sets approval_policy=never, but Codex may still load user Codex config and built-in command execution or read-only file capabilities.';
|
||||
|
||||
export const CODEX_ISOLATION_WARNING_FIX =
|
||||
'Use llm.provider.backend: claude-code when you need stricter Claude-Code-style runtime tool isolation, or remove host Codex MCP/tool config before running untrusted prompts through the codex backend.';
|
||||
|
||||
export function formatCodexIsolationWarning(): string {
|
||||
return `${CODEX_ISOLATION_WARNING} ${CODEX_ISOLATION_WARNING_FIX}`;
|
||||
}
|
||||
87
packages/cli/src/context/llm/codex-mcp-runtime-server.ts
Normal file
87
packages/cli/src/context/llm/codex-mcp-runtime-server.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import type { Server } from 'node:http';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { KtxMcpServerLike } from '../mcp/types.js';
|
||||
import { runKtxMcpHttpServer, type KtxMcpHttpServerHandle } from '../../mcp-http-server.js';
|
||||
import type { KtxRuntimeToolSet } from './runtime-port.js';
|
||||
import { normalizeKtxRuntimeToolOutput } from './runtime-tools.js';
|
||||
|
||||
/** @internal */
|
||||
export interface CreateCodexRuntimeMcpServerInput {
|
||||
server?: KtxMcpServerLike;
|
||||
toolSet: KtxRuntimeToolSet;
|
||||
}
|
||||
|
||||
export interface CodexRuntimeMcpServerHandle {
|
||||
url: string;
|
||||
bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN';
|
||||
bearerToken: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
type RunServer = typeof runKtxMcpHttpServer;
|
||||
|
||||
export interface StartCodexRuntimeMcpServerInput {
|
||||
projectDir: string;
|
||||
toolSet: KtxRuntimeToolSet;
|
||||
runServer?: RunServer;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createCodexRuntimeMcpServer(input: CreateCodexRuntimeMcpServerInput): KtxMcpServerLike {
|
||||
const server =
|
||||
input.server ??
|
||||
(new McpServer({
|
||||
name: 'ktx-runtime',
|
||||
version: '0.0.0',
|
||||
}) as KtxMcpServerLike);
|
||||
|
||||
for (const descriptor of Object.values(input.toolSet)) {
|
||||
server.registerTool(
|
||||
descriptor.name,
|
||||
{
|
||||
description: descriptor.description,
|
||||
inputSchema: descriptor.inputSchema.shape,
|
||||
},
|
||||
async (toolInput) => {
|
||||
const normalized = normalizeKtxRuntimeToolOutput(await descriptor.execute(toolInput));
|
||||
return {
|
||||
content: [{ type: 'text', text: normalized.markdown }],
|
||||
...(normalized.structured !== undefined && normalized.structured !== null && typeof normalized.structured === 'object'
|
||||
? { structuredContent: normalized.structured as object }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function serverPort(server: Server, fallback: number): number {
|
||||
const address = server.address();
|
||||
return typeof address === 'object' && address ? address.port : fallback;
|
||||
}
|
||||
|
||||
export async function startCodexRuntimeMcpServer(
|
||||
input: StartCodexRuntimeMcpServerInput,
|
||||
): Promise<CodexRuntimeMcpServerHandle> {
|
||||
const bearerToken = randomBytes(32).toString('hex');
|
||||
const runServer = input.runServer ?? runKtxMcpHttpServer;
|
||||
const handle = (await runServer({
|
||||
projectDir: input.projectDir,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
token: bearerToken,
|
||||
allowedHosts: ['127.0.0.1', 'localhost'],
|
||||
allowedOrigins: [],
|
||||
createMcpServer: () => createCodexRuntimeMcpServer({ toolSet: input.toolSet }) as McpServer,
|
||||
})) as KtxMcpHttpServerHandle;
|
||||
const port = serverPort(handle.server, 0);
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/mcp`,
|
||||
bearerTokenEnvVar: 'KTX_CODEX_RUNTIME_MCP_TOKEN',
|
||||
bearerToken,
|
||||
close: () => handle.close(),
|
||||
};
|
||||
}
|
||||
20
packages/cli/src/context/llm/codex-models.ts
Normal file
20
packages/cli/src/context/llm/codex-models.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export const DEFAULT_CODEX_MODEL = 'gpt-5.5';
|
||||
|
||||
const CODEX_MODEL_ALIASES: Record<string, string> = {
|
||||
codex: DEFAULT_CODEX_MODEL,
|
||||
default: DEFAULT_CODEX_MODEL,
|
||||
};
|
||||
|
||||
const EXPLICIT_CODEX_MODEL_ID = /^(?:gpt|codex)-[a-z0-9][a-z0-9._-]*$/i;
|
||||
|
||||
export function resolveCodexModel(model: string): string {
|
||||
const normalized = model.trim();
|
||||
const alias = CODEX_MODEL_ALIASES[normalized];
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
if (EXPLICIT_CODEX_MODEL_ID.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Unsupported Codex model "${model}". Use codex, default, or a gpt-* / codex-* model id.`);
|
||||
}
|
||||
38
packages/cli/src/context/llm/codex-runtime-config.ts
Normal file
38
packages/cli/src/context/llm/codex-runtime-config.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
interface CodexRuntimeMcpConfig {
|
||||
url: string;
|
||||
bearerTokenEnvVar: string;
|
||||
bearerToken: string;
|
||||
toolNames: string[];
|
||||
}
|
||||
|
||||
export interface BuildCodexRuntimeConfigInput {
|
||||
model: string;
|
||||
mcp?: CodexRuntimeMcpConfig;
|
||||
}
|
||||
|
||||
export interface CodexRuntimeConfig {
|
||||
configOverrides: Record<string, unknown>;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export function buildCodexRuntimeConfig(input: BuildCodexRuntimeConfigInput): CodexRuntimeConfig {
|
||||
const configOverrides: Record<string, unknown> = {
|
||||
history: { persistence: 'none' },
|
||||
};
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
if (input.mcp) {
|
||||
configOverrides.mcp_servers = {
|
||||
ktx: {
|
||||
url: input.mcp.url,
|
||||
bearer_token_env_var: input.mcp.bearerTokenEnvVar,
|
||||
enabled_tools: input.mcp.toolNames,
|
||||
default_tools_approval_mode: 'approve',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
env[input.mcp.bearerTokenEnvVar] = input.mcp.bearerToken;
|
||||
}
|
||||
|
||||
return { configOverrides, env };
|
||||
}
|
||||
445
packages/cli/src/context/llm/codex-runtime.ts
Normal file
445
packages/cli/src/context/llm/codex-runtime.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
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,
|
||||
type CodexRuntimeMcpServerHandle,
|
||||
} from './codex-mcp-runtime-server.js';
|
||||
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,
|
||||
KtxLlmRuntimePort,
|
||||
KtxRuntimeToolSet,
|
||||
LlmTokenUsage,
|
||||
RunLoopParams,
|
||||
RunLoopResult,
|
||||
} from './runtime-port.js';
|
||||
|
||||
export interface CodexKtxLlmRuntimeDeps {
|
||||
projectDir: string;
|
||||
modelSlots: { default: string } & Partial<Record<string, string>>;
|
||||
runner?: CodexSdkRunner;
|
||||
startMcpServer?: (input: { projectDir: string; toolSet: KtxRuntimeToolSet }) => Promise<CodexRuntimeMcpServerHandle>;
|
||||
logger?: KtxLogger;
|
||||
rateLimitGovernor?: Pick<RateLimitGovernor, 'waitForReady' | 'report' | 'maxRetryAttempts'>;
|
||||
}
|
||||
|
||||
function modelForRole(modelSlots: CodexKtxLlmRuntimeDeps['modelSlots'], role: string): string {
|
||||
return resolveCodexModel(modelSlots[role] ?? modelSlots.default);
|
||||
}
|
||||
|
||||
function promptWithSystem(system: string | undefined, prompt: string): string {
|
||||
return [system, prompt].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
interface CollectCodexEventsOptions {
|
||||
stepBudget?: number;
|
||||
abortController?: AbortController;
|
||||
onStep?: (stepIndex: number) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface CollectCodexEventsResult {
|
||||
events: unknown[];
|
||||
budgetExceeded: boolean;
|
||||
streamError?: Error;
|
||||
}
|
||||
|
||||
function eventRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function isTurnCompleted(event: unknown): boolean {
|
||||
return eventRecord(event)?.type === 'turn.completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the Codex stream once, emitting a step as each agent action completes
|
||||
* so callers see live progress and the step budget is enforced mid-run. Every
|
||||
* completed agent-action item counts (see {@link isCompletedAgentStep}), so
|
||||
* built-in `command_execution` steps decrement the budget the same as
|
||||
* `mcp_tool_call`s. A turn that produced no actions still counts as one step,
|
||||
* matching the metrics summary and the AI SDK backend.
|
||||
*/
|
||||
async function collectEvents(
|
||||
events: AsyncIterable<unknown>,
|
||||
options: CollectCodexEventsOptions = {},
|
||||
): Promise<CollectCodexEventsResult> {
|
||||
const collected: unknown[] = [];
|
||||
let completedSteps = 0;
|
||||
let sawActionStep = false;
|
||||
let budgetExceeded = false;
|
||||
let streamError: Error | undefined;
|
||||
|
||||
// The SDK yields every stdout event, then throws on a non-zero codex exec
|
||||
// exit. Catch that throw so the events already collected (which carry the
|
||||
// real `turn.failed`/`error` reason) survive for the summary; the masked
|
||||
// exit message is kept only as a fallback when no error event was emitted.
|
||||
try {
|
||||
for await (const event of events) {
|
||||
collected.push(event);
|
||||
|
||||
const isActionStep = isCompletedAgentStep(event);
|
||||
if (isActionStep) {
|
||||
sawActionStep = true;
|
||||
} else if (sawActionStep || !isTurnCompleted(event)) {
|
||||
// Only fall back to counting a bare turn as a step when the turn produced
|
||||
// no agent actions; a completed turn is terminal, so it never aborts.
|
||||
continue;
|
||||
}
|
||||
|
||||
completedSteps += 1;
|
||||
await options.onStep?.(completedSteps);
|
||||
if (isActionStep && options.stepBudget !== undefined && completedSteps >= options.stepBudget) {
|
||||
budgetExceeded = true;
|
||||
options.abortController?.abort();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
streamError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
return { events: collected, budgetExceeded, ...(streamError ? { streamError } : {}) };
|
||||
}
|
||||
|
||||
function metrics(summary: CodexExecEventSummary, startedAt: number): { totalMs: number; usage: LlmTokenUsage } {
|
||||
return { totalMs: Date.now() - startedAt, usage: summary.usage };
|
||||
}
|
||||
|
||||
function summaryError(summary: CodexExecEventSummary, streamError?: Error): Error | undefined {
|
||||
// A `turn.failed`/`error` event carries the real reason; prefer it over the
|
||||
// SDK's generic non-zero-exit throw. Fall back to the stream error only when
|
||||
// no event explained the failure (e.g. spawn failure or auth before a turn).
|
||||
if (summary.error) {
|
||||
return summary.error;
|
||||
}
|
||||
if (summary.toolFailures.length > 0) {
|
||||
return new Error(`Codex runtime tool call failed: ${summary.toolFailures.join('; ')}`);
|
||||
}
|
||||
return streamError;
|
||||
}
|
||||
|
||||
function assertSuccessfulText(summary: CodexExecEventSummary, streamError?: Error): string {
|
||||
const error = summaryError(summary, streamError);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (!summary.finalText.trim()) {
|
||||
throw new Error('Codex completed without an agent message');
|
||||
}
|
||||
return summary.finalText;
|
||||
}
|
||||
|
||||
function parseStructuredOutput<TOutput, TSchema extends z.ZodType<TOutput>>(schema: TSchema, text: string): TOutput {
|
||||
try {
|
||||
return schema.parse(JSON.parse(text));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Codex structured output failed validation: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function mcpForTools(input: {
|
||||
projectDir: string;
|
||||
toolSet?: KtxRuntimeToolSet;
|
||||
startMcpServer: CodexKtxLlmRuntimeDeps['startMcpServer'];
|
||||
}): Promise<CodexRuntimeMcpServerHandle | undefined> {
|
||||
if (!input.toolSet || Object.keys(input.toolSet).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return (input.startMcpServer ?? startCodexRuntimeMcpServer)({
|
||||
projectDir: input.projectDir,
|
||||
toolSet: input.toolSet,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
constructor(private readonly deps: CodexKtxLlmRuntimeDeps) {
|
||||
this.runner = deps.runner ?? new CodexSdkCliRunner();
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
private async runWithRateLimitRetry<T>(
|
||||
abortSignal: AbortSignal | undefined,
|
||||
run: () => Promise<T>,
|
||||
getError: (result: T) => Error | undefined,
|
||||
): Promise<T> {
|
||||
// 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<string> {
|
||||
const startedAt = Date.now();
|
||||
const model = modelForRole(this.deps.modelSlots, input.role);
|
||||
const mcp = await mcpForTools({
|
||||
projectDir: this.deps.projectDir,
|
||||
toolSet: input.tools,
|
||||
startMcpServer: this.deps.startMcpServer,
|
||||
});
|
||||
try {
|
||||
const config = buildCodexRuntimeConfig({
|
||||
model,
|
||||
...(mcp
|
||||
? {
|
||||
mcp: {
|
||||
url: mcp.url,
|
||||
bearerTokenEnvVar: mcp.bearerTokenEnvVar,
|
||||
bearerToken: mcp.bearerToken,
|
||||
toolNames: runtimeToolNames(input.tools),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
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),
|
||||
);
|
||||
input.onMetrics?.(metrics(result.summary, startedAt));
|
||||
return assertSuccessfulText(result.summary, result.collected.streamError);
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async generateObject<TOutput, TSchema extends z.ZodType<TOutput>>(
|
||||
input: KtxGenerateObjectInput<TOutput, TSchema>,
|
||||
): Promise<TOutput> {
|
||||
const startedAt = Date.now();
|
||||
const model = modelForRole(this.deps.modelSlots, input.role);
|
||||
const mcp = await mcpForTools({
|
||||
projectDir: this.deps.projectDir,
|
||||
toolSet: input.tools,
|
||||
startMcpServer: this.deps.startMcpServer,
|
||||
});
|
||||
try {
|
||||
const config = buildCodexRuntimeConfig({
|
||||
model,
|
||||
...(mcp
|
||||
? {
|
||||
mcp: {
|
||||
url: mcp.url,
|
||||
bearerTokenEnvVar: mcp.bearerTokenEnvVar,
|
||||
bearerToken: mcp.bearerToken,
|
||||
toolNames: runtimeToolNames(input.tools),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
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<string, unknown>,
|
||||
...(input.abortSignal ? { signal: input.abortSignal } : {}),
|
||||
}),
|
||||
);
|
||||
const summary = summarizeCodexExecEvents(collected.events, { startedAt });
|
||||
return { collected, summary };
|
||||
},
|
||||
({ collected, summary }) => summaryError(summary, collected.streamError),
|
||||
);
|
||||
input.onMetrics?.(metrics(result.summary, startedAt));
|
||||
return parseStructuredOutput(input.schema, assertSuccessfulText(result.summary, result.collected.streamError));
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async runAgentLoop(params: RunLoopParams): Promise<RunLoopResult> {
|
||||
const startedAt = Date.now();
|
||||
const model = modelForRole(this.deps.modelSlots, params.modelRole);
|
||||
let mcp: CodexRuntimeMcpServerHandle | undefined;
|
||||
try {
|
||||
mcp = await mcpForTools({
|
||||
projectDir: this.deps.projectDir,
|
||||
toolSet: params.toolSet,
|
||||
startMcpServer: this.deps.startMcpServer,
|
||||
});
|
||||
const config = buildCodexRuntimeConfig({
|
||||
model,
|
||||
...(mcp
|
||||
? {
|
||||
mcp: {
|
||||
url: mcp.url,
|
||||
bearerTokenEnvVar: mcp.bearerTokenEnvVar,
|
||||
bearerToken: mcp.bearerToken,
|
||||
toolNames: runtimeToolNames(params.toolSet),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const onStep = async (stepIndex: number): Promise<void> => {
|
||||
try {
|
||||
await params.onStepFinish?.({ stepIndex, stepBudget: params.stepBudget });
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[codex-runner] onStepFinish callback threw; ignoring: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
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 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: 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',
|
||||
error: err,
|
||||
metrics: { totalMs: Date.now() - startedAt, usage: {}, stepCount: 0, stepBoundariesMs: [] },
|
||||
};
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected model is not an auth failure: Codex authenticated, connected, and
|
||||
// the API refused the model id. These markers come from the API error envelope
|
||||
// (e.g. "model is not supported", "invalid_request_error").
|
||||
const MODEL_UNAVAILABLE_MARKERS =
|
||||
/\bnot supported\b|\bnot available\b|\bdoes not exist\b|invalid_request_error|\bunknown model\b|\bunsupported model\b/i;
|
||||
|
||||
function describeCodexProbeFailure(model: string, message: string): { message: string; fix: string } {
|
||||
if (MODEL_UNAVAILABLE_MARKERS.test(message)) {
|
||||
const fix = `Run \`codex\` to see the models your account supports, then set llm.models.default in ktx.yaml (or rerun \`ktx setup\`).`;
|
||||
return {
|
||||
message: `Codex is authenticated, but the configured model "${model}" is not available for this Codex account. ${fix} Details: ${message}`,
|
||||
fix,
|
||||
};
|
||||
}
|
||||
const fix = `Authenticate Codex locally with the Codex CLI, verify the Codex CLI is installed, then rerun setup or \`ktx status\`.`;
|
||||
return {
|
||||
message: `Codex authentication is not usable. ${fix} Details: ${message}`,
|
||||
fix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCodexAuthProbe(input: {
|
||||
projectDir: string;
|
||||
model: string;
|
||||
runner?: CodexSdkRunner;
|
||||
}): Promise<{ ok: true } | { ok: false; message: string; fix: string }> {
|
||||
let model: string;
|
||||
try {
|
||||
model = resolveCodexModel(input.model);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
fix: 'Set llm.models.default in ktx.yaml to a supported codex model (codex, default, or a gpt-* / codex-* id), or rerun `ktx setup`.',
|
||||
};
|
||||
}
|
||||
|
||||
const runtime = new CodexKtxLlmRuntime({
|
||||
projectDir: input.projectDir,
|
||||
modelSlots: { default: model },
|
||||
...(input.runner ? { runner: input.runner } : {}),
|
||||
});
|
||||
try {
|
||||
await runtime.generateText({ role: 'default', prompt: 'Reply with exactly: ok' });
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { ok: false, ...describeCodexProbeFailure(model, message) };
|
||||
}
|
||||
}
|
||||
96
packages/cli/src/context/llm/codex-sdk-runner.ts
Normal file
96
packages/cli/src/context/llm/codex-sdk-runner.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Codex, type CodexOptions, type ThreadOptions, type TurnOptions } from '@openai/codex-sdk';
|
||||
|
||||
export interface CodexSdkRunnerInput {
|
||||
projectDir: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
configOverrides?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CodexSdkRunner {
|
||||
runStreamed(input: CodexSdkRunnerInput): Promise<AsyncIterable<unknown>>;
|
||||
}
|
||||
|
||||
type CodexThread = {
|
||||
runStreamed(input: string, turnOptions?: TurnOptions): Promise<{ events: AsyncIterable<unknown> }>;
|
||||
};
|
||||
|
||||
type CodexClient = {
|
||||
startThread(options: ThreadOptions): CodexThread;
|
||||
};
|
||||
|
||||
type CodexConstructor = new (options?: CodexOptions) => CodexClient;
|
||||
|
||||
export interface CodexSdkCliRunnerOptions {
|
||||
envBase?: NodeJS.ProcessEnv;
|
||||
codexPathOverride?: string;
|
||||
}
|
||||
|
||||
const CODEX_ENV_ALLOWLIST = new Set([
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'APPDATA',
|
||||
'LOCALAPPDATA',
|
||||
'XDG_CONFIG_HOME',
|
||||
'CODEX_HOME',
|
||||
'CODEX_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'PATH',
|
||||
'Path',
|
||||
'SYSTEMROOT',
|
||||
'COMSPEC',
|
||||
'TMPDIR',
|
||||
'TMP',
|
||||
'TEMP',
|
||||
'SSL_CERT_FILE',
|
||||
'SSL_CERT_DIR',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
'HTTPS_PROXY',
|
||||
'HTTP_PROXY',
|
||||
'ALL_PROXY',
|
||||
'NO_PROXY',
|
||||
]);
|
||||
|
||||
function buildCodexSdkEnv(baseEnv: NodeJS.ProcessEnv, overrides: Record<string, string> | undefined): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const key of CODEX_ENV_ALLOWLIST) {
|
||||
const value = baseEnv[key];
|
||||
if (typeof value === 'string') {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return { ...env, ...(overrides ?? {}) };
|
||||
}
|
||||
|
||||
export class CodexSdkCliRunner implements CodexSdkRunner {
|
||||
constructor(private readonly options: CodexSdkCliRunnerOptions = {}) {}
|
||||
|
||||
async runStreamed(input: CodexSdkRunnerInput): Promise<AsyncIterable<unknown>> {
|
||||
const CodexClass = Codex as CodexConstructor;
|
||||
const codex = new CodexClass({
|
||||
...(input.configOverrides ? { config: input.configOverrides as CodexOptions['config'] } : {}),
|
||||
env: buildCodexSdkEnv(this.options.envBase ?? process.env, input.env),
|
||||
...(this.options.codexPathOverride ? { codexPathOverride: this.options.codexPathOverride } : {}),
|
||||
});
|
||||
const thread = codex.startThread({
|
||||
workingDirectory: input.projectDir,
|
||||
skipGitRepoCheck: true,
|
||||
model: input.model,
|
||||
sandboxMode: 'read-only',
|
||||
webSearchMode: 'disabled',
|
||||
approvalPolicy: 'never',
|
||||
});
|
||||
const turnOptions: TurnOptions = {
|
||||
...(input.outputSchema ? { outputSchema: input.outputSchema } : {}),
|
||||
...(input.signal ? { signal: input.signal } : {}),
|
||||
};
|
||||
const streamed = await thread.runStreamed(
|
||||
input.prompt,
|
||||
Object.keys(turnOptions).length > 0 ? turnOptions : undefined,
|
||||
);
|
||||
return streamed.events;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,29 @@ import { resolveKtxConfigReference } from '../core/config-reference.js';
|
|||
import type { KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from '../project/config.js';
|
||||
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<typeof ClaudeCodeKtxLlmRuntime>[0] & {
|
||||
rateLimitGovernor?: RateLimitGovernor;
|
||||
};
|
||||
type CodexRuntimeDeps = ConstructorParameters<typeof CodexKtxLlmRuntime>[0] & {
|
||||
rateLimitGovernor?: RateLimitGovernor;
|
||||
};
|
||||
type AiSdkRuntimeDeps = ConstructorParameters<typeof AiSdkKtxLlmRuntime>[0] & {
|
||||
rateLimitGovernor?: RateLimitGovernor;
|
||||
};
|
||||
|
||||
interface LocalConfigDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
projectDir?: string;
|
||||
rateLimitGovernor?: RateLimitGovernor;
|
||||
createKtxLlmProvider?: typeof createKtxLlmProvider;
|
||||
createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider;
|
||||
createClaudeCodeRuntime?: (deps: ConstructorParameters<typeof ClaudeCodeKtxLlmRuntime>[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 {
|
||||
|
|
@ -104,7 +118,7 @@ export function createLocalKtxLlmProviderFromConfig(
|
|||
deps: LocalConfigDeps = {},
|
||||
): KtxLlmProvider | null {
|
||||
const resolved = resolveLocalKtxLlmConfig(config, deps.env ?? process.env);
|
||||
if (!resolved || resolved.backend === 'claude-code') {
|
||||
if (!resolved || resolved.backend === 'claude-code' || resolved.backend === 'codex') {
|
||||
return null;
|
||||
}
|
||||
return (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved);
|
||||
|
|
@ -127,10 +141,25 @@ export function createLocalKtxLlmRuntimeFromConfig(
|
|||
projectDir,
|
||||
modelSlots: resolved.modelSlots,
|
||||
env: deps.env,
|
||||
rateLimitGovernor: deps.rateLimitGovernor,
|
||||
});
|
||||
}
|
||||
if (resolved.backend === 'codex') {
|
||||
const projectDir = deps.projectDir;
|
||||
if (!projectDir) {
|
||||
throw new Error('projectDir is required when creating the codex LLM runtime');
|
||||
}
|
||||
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(
|
||||
|
|
|
|||
387
packages/cli/src/context/llm/rate-limit-governor.ts
Normal file
387
packages/cli/src/context/llm/rate-limit-governor.ts
Normal file
|
|
@ -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<void>;
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export type RateLimitRelease = () => void;
|
||||
type Subscriber = (state: RateLimitWaitState) => void;
|
||||
|
||||
const defaultSleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
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<RateLimitGovernorConfig> & { retry?: Partial<RateLimitRetryConfig> } = {},
|
||||
): 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<void>;
|
||||
private readonly random: () => number;
|
||||
private readonly subscribers = new Set<Subscriber>();
|
||||
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<RateLimitProvider, number>();
|
||||
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<void> {
|
||||
throwIfAborted(signal);
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
await this.waitForPause(signal);
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
|
||||
async acquireWorkSlot(signal?: AbortSignal): Promise<RateLimitRelease> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<RateLimitWaitState['kind'], 'wait_started' | 'wait_tick' | 'wait_finished'>): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ export interface RunLoopParams {
|
|||
stepBudget: number;
|
||||
telemetryTags: Record<string, string>;
|
||||
onStepFinish?: (info: RunLoopStepInfo) => void | Promise<void>;
|
||||
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<TOutput, TSchema extends z.ZodType<TOutput>> {
|
||||
|
|
@ -74,6 +76,7 @@ export interface KtxGenerateObjectInput<TOutput, TSchema extends z.ZodType<TOutp
|
|||
temperature?: number;
|
||||
schema: TSchema;
|
||||
onMetrics?: (metrics: { totalMs: number; usage: LlmTokenUsage }) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface KtxLlmRuntimePort {
|
||||
|
|
|
|||
|
|
@ -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<TSchema extends z.ZodType>(
|
|||
},
|
||||
schema: TSchema,
|
||||
handler: (input: z.infer<TSchema>, context?: KtxMcpToolHandlerContext) => Promise<KtxMcpToolResult>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import YAML from 'yaml';
|
|||
import * as z from 'zod';
|
||||
import { connectionConfigSchema } from './driver-schemas.js';
|
||||
|
||||
const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code'] as const;
|
||||
const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code', 'codex'] as const;
|
||||
const KTX_EMBEDDING_BACKENDS = ['none', 'openai', 'sentence-transformers'] as const;
|
||||
const KTX_PROMPT_CACHE_TTLS = ['5m', '1h'] as const;
|
||||
const KTX_ENRICHMENT_MODES = ['none', 'deterministic', 'llm'] as const;
|
||||
|
|
@ -38,7 +38,7 @@ const llmProviderSchema = z
|
|||
.enum(KTX_LLM_BACKENDS)
|
||||
.default('none')
|
||||
.describe(
|
||||
'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session.',
|
||||
'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session; "codex" uses the local Codex session.',
|
||||
),
|
||||
vertex: vertexProviderSchema.optional().describe('Vertex AI credentials, used when backend is "vertex".'),
|
||||
anthropic: apiCredentialsSchema.optional().describe('Anthropic API credentials, used when backend is "anthropic".'),
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import type { KtxTableRef } from '../scan/types.js';
|
||||
import type {
|
||||
SqlAnalysisBatchItem,
|
||||
SqlAnalysisBatchOptions,
|
||||
SqlAnalysisBatchResult,
|
||||
SqlAnalysisDialect,
|
||||
SqlAnalysisFingerprintResult,
|
||||
|
|
@ -89,6 +91,14 @@ function optionalString(raw: Record<string, unknown>, field: string): string | n
|
|||
throw new Error(`sql analysis response has invalid optional string field ${field}`);
|
||||
}
|
||||
|
||||
function optionalNullableStringField(raw: Record<string, unknown>, field: string): string | null {
|
||||
const value = raw[field];
|
||||
if (value === null || value === undefined || typeof value === 'string') {
|
||||
return value ?? null;
|
||||
}
|
||||
throw new Error(`sql analysis response has invalid optional nullable string field ${field}`);
|
||||
}
|
||||
|
||||
function requiredStringArray(raw: Record<string, unknown>, field: string): string[] {
|
||||
const value = raw[field];
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
||||
|
|
@ -175,10 +185,34 @@ function mapColumnsByClause(raw: Record<string, unknown>): SqlAnalysisBatchResul
|
|||
return result;
|
||||
}
|
||||
|
||||
function requiredTableRef(raw: unknown, field: string): KtxTableRef {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error(`sql analysis response contains invalid table ref in ${field}`);
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const name = record.name;
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new Error(`sql analysis response table ref in ${field} is missing name`);
|
||||
}
|
||||
return {
|
||||
catalog: optionalNullableStringField(record, 'catalog'),
|
||||
db: optionalNullableStringField(record, 'db'),
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredTableRefArray(raw: Record<string, unknown>, field: string): KtxTableRef[] {
|
||||
const value = raw[field];
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`sql analysis response is missing table-ref[] field ${field}`);
|
||||
}
|
||||
return value.map((item, index) => requiredTableRef(item, `${field}.${index}`));
|
||||
}
|
||||
|
||||
function mapBatchResult(raw: Record<string, unknown>): SqlAnalysisBatchResult {
|
||||
const error = optionalString(raw, 'error');
|
||||
return {
|
||||
tablesTouched: requiredStringArray(raw, 'tables_touched'),
|
||||
tablesTouched: requiredTableRefArray(raw, 'tables_touched'),
|
||||
columnsByClause: mapColumnsByClause(raw),
|
||||
...(error !== undefined ? { error } : {}),
|
||||
};
|
||||
|
|
@ -215,10 +249,11 @@ export function createHttpSqlAnalysisPort(options: HttpSqlAnalysisPortOptions):
|
|||
});
|
||||
return mapResult(raw);
|
||||
},
|
||||
async analyzeBatch(items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect) {
|
||||
async analyzeBatch(items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect, options?: SqlAnalysisBatchOptions) {
|
||||
const raw = await requestJson('/sql/analyze-batch', {
|
||||
dialect,
|
||||
items,
|
||||
...(options?.catalog ? { catalog: options.catalog } : {}),
|
||||
});
|
||||
return mapBatchResponse(raw);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { KtxTableRef } from '../scan/types.js';
|
||||
|
||||
export type SqlAnalysisDialect =
|
||||
| 'bigquery'
|
||||
| 'snowflake'
|
||||
|
|
@ -32,8 +34,20 @@ export interface SqlAnalysisBatchItem {
|
|||
sql: string;
|
||||
}
|
||||
|
||||
interface SqlAnalysisCatalogTable extends KtxTableRef {
|
||||
columns?: string[];
|
||||
}
|
||||
|
||||
interface SqlAnalysisCatalog {
|
||||
tables: SqlAnalysisCatalogTable[];
|
||||
}
|
||||
|
||||
export interface SqlAnalysisBatchOptions {
|
||||
catalog?: SqlAnalysisCatalog;
|
||||
}
|
||||
|
||||
export interface SqlAnalysisBatchResult {
|
||||
tablesTouched: string[];
|
||||
tablesTouched: KtxTableRef[];
|
||||
columnsByClause: Partial<Record<SqlAnalysisClause, string[]>>;
|
||||
error?: string | null;
|
||||
}
|
||||
|
|
@ -48,6 +62,7 @@ export interface SqlAnalysisPort {
|
|||
analyzeBatch(
|
||||
items: SqlAnalysisBatchItem[],
|
||||
dialect: SqlAnalysisDialect,
|
||||
options?: SqlAnalysisBatchOptions,
|
||||
): Promise<Map<string, SqlAnalysisBatchResult>>;
|
||||
validateReadOnly(sql: string, dialect: SqlAnalysisDialect): Promise<SqlReadOnlyValidationResult>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string>([
|
||||
['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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
35
packages/cli/src/io/buffered-command-io.ts
Normal file
35
packages/cli/src/io/buffered-command-io.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { KtxCliIo } from '../cli-runtime.js';
|
||||
|
||||
export interface BufferedCommandIo extends KtxCliIo {
|
||||
stdoutText(): string;
|
||||
stderrText(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures stdout/stderr from a command (e.g. `runKtxConnection`) into buffers
|
||||
* instead of the terminal. Callers decide whether to flush the captured text to
|
||||
* the user or discard it.
|
||||
*/
|
||||
export function createBufferedCommandIo(): BufferedCommandIo {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
stdout: {
|
||||
isTTY: false,
|
||||
write(chunk: string) {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
stdoutText() {
|
||||
return stdout;
|
||||
},
|
||||
stderrText() {
|
||||
return stderr;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import type { LanguageModel, TelemetrySettings, ToolCallRepairFunction, ToolSet
|
|||
export const KTX_MODEL_ROLES = ['default', 'triage', 'candidateExtraction', 'curator', 'reconcile', 'repair'] as const;
|
||||
|
||||
export type KtxModelRole = (typeof KTX_MODEL_ROLES)[number];
|
||||
type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code';
|
||||
type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code' | 'codex';
|
||||
export type KtxPromptCacheTtl = '5m' | '1h';
|
||||
|
||||
type KtxJsonValue =
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import { isKtxSqliteConnectionConfig } from './connectors/sqlite/connector.js';
|
|||
import { createSqlServerLiveDatabaseIntrospection } from './connectors/sqlserver/live-database-introspection.js';
|
||||
import { isKtxSqlServerConnectionConfig } from './connectors/sqlserver/connector.js';
|
||||
import { BigQueryHistoricSqlQueryHistoryReader } from './context/ingest/adapters/historic-sql/bigquery-query-history-reader.js';
|
||||
import { queryHistoryDialectForConnection } from './context/ingest/adapters/historic-sql/connection-dialect.js';
|
||||
import { historicSqlDialectForConnectionDriver } from './context/ingest/adapters/historic-sql/connection-dialect.js';
|
||||
import { createDaemonLiveDatabaseIntrospection } from './context/ingest/adapters/live-database/daemon-introspection.js';
|
||||
import { createDefaultLocalIngestAdapters, type DefaultLocalIngestAdaptersOptions } from './context/ingest/local-adapters.js';
|
||||
import type { HistoricSqlReader } from './context/ingest/adapters/historic-sql/types.js';
|
||||
import type { HistoricSqlDialect, HistoricSqlReader } from './context/ingest/adapters/historic-sql/types.js';
|
||||
import type {
|
||||
LiveDatabaseIntrospectionOptions,
|
||||
LiveDatabaseIntrospectionPort,
|
||||
|
|
@ -31,7 +31,7 @@ import {
|
|||
createManagedDaemonLookerTableIdentifierParser,
|
||||
createManagedDaemonSqlAnalysisPort,
|
||||
managedDaemonDatabaseIntrospectionOptions,
|
||||
type ManagedPythonCoreDaemonOptions,
|
||||
type ManagedPythonDaemonHttpOptions,
|
||||
} from './managed-python-http.js';
|
||||
import type { KtxOperationalLogger } from './io/logger.js';
|
||||
import { resolveKtxConfigReference } from './context/core/config-reference.js';
|
||||
|
|
@ -161,10 +161,17 @@ export interface KtxCliLocalIngestAdaptersOptions extends DefaultLocalIngestAdap
|
|||
historicSqlConnectionId?: string;
|
||||
sqlAnalysis?: SqlAnalysisPort;
|
||||
sqlAnalysisUrl?: string;
|
||||
managedDaemon?: ManagedPythonCoreDaemonOptions;
|
||||
managedDaemon?: ManagedPythonDaemonHttpOptions;
|
||||
logger?: KtxOperationalLogger;
|
||||
}
|
||||
|
||||
export interface KtxCliHistoricSqlRuntime {
|
||||
dialect: HistoricSqlDialect;
|
||||
sqlAnalysis: SqlAnalysisPort;
|
||||
reader: HistoricSqlReader;
|
||||
queryClient: unknown;
|
||||
}
|
||||
|
||||
function createEphemeralPostgresHistoricSqlClient(project: KtxLocalProject, connectionId: string) {
|
||||
const connection = project.config.connections[connectionId] as KtxPostgresConnectionConfig | undefined;
|
||||
const inputDriver = connection?.driver ?? 'unknown';
|
||||
|
|
@ -262,13 +269,21 @@ function bigQueryRegion(connection: KtxBigQueryConnectionConfig): string {
|
|||
: 'us';
|
||||
}
|
||||
|
||||
function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCliLocalIngestAdaptersOptions) {
|
||||
function historicSqlOptionsForLocalRun(
|
||||
project: KtxLocalProject,
|
||||
options: KtxCliLocalIngestAdaptersOptions,
|
||||
): KtxCliHistoricSqlRuntime | undefined {
|
||||
const connectionId = options.historicSqlConnectionId;
|
||||
if (!connectionId) {
|
||||
return undefined;
|
||||
}
|
||||
const connection = project.config.connections[connectionId];
|
||||
const dialect = queryHistoryDialectForConnection(connection);
|
||||
// historicSqlConnectionId is only set when query history was explicitly
|
||||
// requested for this run (e.g. `--query-history`), so resolve the dialect from
|
||||
// driver capability rather than the persisted context.queryHistory.enabled
|
||||
// flag — otherwise the adapter is missing and findAdapter('historic-sql')
|
||||
// throws even though the run asked for it.
|
||||
const dialect = historicSqlDialectForConnectionDriver(connection);
|
||||
if (!dialect) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -280,6 +295,7 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli
|
|||
if (dialect === 'postgres') {
|
||||
return {
|
||||
...base,
|
||||
dialect,
|
||||
reader: new PostgresPgssReader() satisfies HistoricSqlReader,
|
||||
queryClient: createEphemeralPostgresHistoricSqlClient(project, connectionId),
|
||||
};
|
||||
|
|
@ -292,6 +308,7 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli
|
|||
}
|
||||
return {
|
||||
...base,
|
||||
dialect,
|
||||
reader: new BigQueryHistoricSqlQueryHistoryReader({
|
||||
projectId: bigQueryProjectId(connection, process.env),
|
||||
region: bigQueryRegion(connection),
|
||||
|
|
@ -302,6 +319,7 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli
|
|||
|
||||
return {
|
||||
...base,
|
||||
dialect,
|
||||
reader: new SnowflakeHistoricSqlQueryHistoryReader() satisfies HistoricSqlReader,
|
||||
queryClient: {
|
||||
async executeQuery(query: string) {
|
||||
|
|
@ -313,11 +331,24 @@ function historicSqlOptionsForLocalRun(project: KtxLocalProject, options: KtxCli
|
|||
};
|
||||
}
|
||||
|
||||
export function createKtxCliHistoricSqlRuntime(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
options: KtxCliLocalIngestAdaptersOptions = {},
|
||||
): KtxCliHistoricSqlRuntime | undefined {
|
||||
return historicSqlOptionsForLocalRun(project, {
|
||||
...options,
|
||||
historicSqlConnectionId: connectionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createKtxCliLocalIngestAdapters(
|
||||
project: KtxLocalProject,
|
||||
options: KtxCliLocalIngestAdaptersOptions = {},
|
||||
): SourceAdapter[] {
|
||||
const historicSql = historicSqlOptionsForLocalRun(project, options);
|
||||
const historicSql = options.historicSqlConnectionId
|
||||
? createKtxCliHistoricSqlRuntime(project, options.historicSqlConnectionId, options)
|
||||
: undefined;
|
||||
const base = createDefaultLocalIngestAdapters(project, {
|
||||
...options,
|
||||
databaseIntrospection: ktxCliDaemonDatabaseIntrospectionOptions(options),
|
||||
|
|
|
|||
|
|
@ -70,8 +70,7 @@ export function formatSetupNextStepLines(state: KtxSetupNextStepState, indent =
|
|||
|
||||
if (!state.contextReady) {
|
||||
return [
|
||||
`${indent}Build KTX context next.`,
|
||||
`${indent}Run ingest to build database schema context before context-source ingest.`,
|
||||
`${indent}Setup is complete. The only step left is to build context for your agents.`,
|
||||
...commandLines(KTX_CONTEXT_BUILD_COMMANDS, indent),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
29
packages/cli/src/progress-port-adapter.ts
Normal file
29
packages/cli/src/progress-port-adapter.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { KtxProgressPort, KtxProgressUpdateOptions } from './context/scan/types.js';
|
||||
import type { KtxIngestProgressUpdate } from './ingest.js';
|
||||
|
||||
export interface AggregateProgressState {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function createAggregateProgressPort(
|
||||
onProgress: (update: KtxIngestProgressUpdate) => void,
|
||||
state: AggregateProgressState = { progress: 0 },
|
||||
start = 0,
|
||||
weight = 1,
|
||||
): KtxProgressPort {
|
||||
return {
|
||||
async update(value: number, message?: string, options?: KtxProgressUpdateOptions): Promise<void> {
|
||||
const absoluteValue = start + Math.max(0, Math.min(1, value)) * weight;
|
||||
state.progress = Math.max(state.progress, Math.min(1, absoluteValue));
|
||||
if (!message) return;
|
||||
onProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(state.progress * 100))),
|
||||
message,
|
||||
...(options?.transient !== undefined ? { transient: options.transient } : {}),
|
||||
});
|
||||
},
|
||||
startPhase(phaseWeight: number): KtxProgressPort {
|
||||
return createAggregateProgressPort(onProgress, state, state.progress, weight * phaseWeight);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -5,18 +5,27 @@ import type { KtxProgressPort } from './context/scan/types.js';
|
|||
import type { KtxCliIo } from './index.js';
|
||||
import type { KtxIngestArgs, KtxIngestDeps, KtxIngestProgressUpdate } from './ingest.js';
|
||||
import { isDatabaseDriver, normalizeConnectionDriver } from './connection-drivers.js';
|
||||
import { resolveQueryHistoryScopeFloor } from './context/ingest/adapters/historic-sql/scope-floor.js';
|
||||
import {
|
||||
ensureManagedPythonCommandRuntime,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
type ManagedPythonCommandRuntime,
|
||||
} from './managed-python-command.js';
|
||||
import type { KtxRuntimeFeature } from './managed-python-runtime.js';
|
||||
import { publicIngestOutputLine } from './public-ingest-copy.js';
|
||||
import {
|
||||
publicDatabaseIngestMessage,
|
||||
publicIngestOutputLine,
|
||||
publicQueryHistoryMessage,
|
||||
} from './public-ingest-copy.js';
|
||||
import { createAggregateProgressPort } from './progress-port-adapter.js';
|
||||
import { resolvePublicIngestRuntimeRequirements } from './runtime-requirements.js';
|
||||
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');
|
||||
|
||||
|
|
@ -129,6 +138,17 @@ const sourceAdapterByDriver = new Map<string, string>([
|
|||
['lookml', 'lookml'],
|
||||
]);
|
||||
|
||||
export function publicProgressMessage(message: string, target: KtxPublicIngestPlanTarget): string {
|
||||
let current = message;
|
||||
if (target.operation === 'database-ingest') {
|
||||
current = publicDatabaseIngestMessage(current);
|
||||
}
|
||||
if (target.steps.includes('query-history')) {
|
||||
current = publicQueryHistoryMessage(current, target.connectionId);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
const queryHistoryDialectByDriver = new Map<string, HistoricSqlDialect>([
|
||||
['postgres', 'postgres'],
|
||||
['bigquery', 'bigquery'],
|
||||
|
|
@ -264,26 +284,35 @@ function positiveInteger(value: unknown): number | undefined {
|
|||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function enabledTablesForConnection(connection: KtxProjectConnectionConfig): string[] | undefined {
|
||||
const raw = connection.enabled_tables;
|
||||
if (!Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const tables = raw.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
return tables.length > 0 ? tables : undefined;
|
||||
}
|
||||
|
||||
function queryHistoryPullConfig(input: {
|
||||
/** @internal */
|
||||
export function queryHistoryPullConfig(input: {
|
||||
stored: Record<string, unknown>;
|
||||
dialect: HistoricSqlDialect;
|
||||
windowDays?: number;
|
||||
enabledTables?: string[];
|
||||
enabledTables?: KtxTableRef[];
|
||||
enabledSchemas?: string[];
|
||||
modeledTableCatalog?: KtxTableRef[];
|
||||
scopeFloorWarnings?: string[];
|
||||
}): Record<string, unknown> {
|
||||
const { enabled: _enabled, dialect: _dialect, ...storedConfig } = input.stored;
|
||||
const {
|
||||
enabled: _enabled,
|
||||
dialect: _dialect,
|
||||
enabledTables: _enabledTables,
|
||||
enabledSchemas: _enabledSchemas,
|
||||
scopeFloorWarnings: _scopeFloorWarnings,
|
||||
...storedConfig
|
||||
} = input.stored;
|
||||
return {
|
||||
...storedConfig,
|
||||
dialect: input.dialect,
|
||||
...(input.enabledTables ? { enabledTables: input.enabledTables } : {}),
|
||||
...(input.enabledTables && input.enabledTables.length > 0 ? { enabledTables: input.enabledTables } : {}),
|
||||
...(input.enabledSchemas && input.enabledSchemas.length > 0 ? { enabledSchemas: input.enabledSchemas } : {}),
|
||||
...(input.modeledTableCatalog && input.modeledTableCatalog.length > 0
|
||||
? { modeledTableCatalog: input.modeledTableCatalog }
|
||||
: {}),
|
||||
...(input.scopeFloorWarnings && input.scopeFloorWarnings.length > 0
|
||||
? { scopeFloorWarnings: input.scopeFloorWarnings }
|
||||
: {}),
|
||||
...(input.windowDays !== undefined ? { windowDays: input.windowDays } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -344,7 +373,6 @@ function resolveDatabaseTargetOptions(input: {
|
|||
stored: storedQh,
|
||||
dialect,
|
||||
windowDays: queryHistory.windowDays,
|
||||
enabledTables: enabledTablesForConnection(input.connection),
|
||||
}),
|
||||
},
|
||||
steps: ['database-schema', 'query-history'],
|
||||
|
|
@ -357,6 +385,43 @@ function resolveDatabaseTargetOptions(input: {
|
|||
};
|
||||
}
|
||||
|
||||
async function resolvedQueryHistoryPullConfigForTarget(
|
||||
target: KtxPublicIngestPlanTarget,
|
||||
project: KtxPublicIngestProject,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (target.operation !== 'database-ingest' || target.queryHistory?.enabled !== true || !target.queryHistory.dialect) {
|
||||
return null;
|
||||
}
|
||||
const connection = project.config.connections[target.connectionId];
|
||||
if (!connection) {
|
||||
return (
|
||||
target.queryHistory.pullConfig ??
|
||||
queryHistoryPullConfig({
|
||||
stored: {},
|
||||
dialect: target.queryHistory.dialect,
|
||||
windowDays: target.queryHistory.windowDays,
|
||||
})
|
||||
);
|
||||
}
|
||||
const stored = storedQueryHistory(connection);
|
||||
const scopeFloor = await resolveQueryHistoryScopeFloor({
|
||||
projectDir: project.projectDir,
|
||||
connectionId: target.connectionId,
|
||||
driver: target.driver,
|
||||
connection: connection as Record<string, unknown>,
|
||||
storedQueryHistory: stored,
|
||||
});
|
||||
return queryHistoryPullConfig({
|
||||
stored,
|
||||
dialect: target.queryHistory.dialect,
|
||||
windowDays: target.queryHistory.windowDays,
|
||||
enabledTables: scopeFloor.enabledTables,
|
||||
enabledSchemas: scopeFloor.enabledSchemas,
|
||||
modeledTableCatalog: scopeFloor.modeledTableCatalog,
|
||||
scopeFloorWarnings: scopeFloor.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
function enrichmentReadinessGaps(config: KtxProjectConfig): string[] {
|
||||
const gaps: string[] = [];
|
||||
if (config.llm.provider.backend === 'none' || !config.llm.models.default) {
|
||||
|
|
@ -619,6 +684,9 @@ async function emitIngestCompleted(input: {
|
|||
io: KtxCliIo;
|
||||
}): Promise<void> {
|
||||
const failed = resultFailed(input.result);
|
||||
const failureDetail = failed
|
||||
? formatErrorDetail(input.result.steps.find((step) => step.status === 'failed')?.detail)
|
||||
: undefined;
|
||||
await emitTelemetryEvent({
|
||||
name: 'ingest_completed',
|
||||
projectDir: input.args.projectDir,
|
||||
|
|
@ -635,6 +703,7 @@ async function emitIngestCompleted(input: {
|
|||
rowsBucket: rowsBucket(),
|
||||
durationMs: Math.max(0, performance.now() - input.startedAt),
|
||||
outcome: failed ? 'error' : 'ok',
|
||||
...(failureDetail ? { errorDetail: failureDetail } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -729,6 +798,80 @@ function createCapturedPublicIngestIo(): CapturedPublicIngestIo {
|
|||
};
|
||||
}
|
||||
|
||||
function isCapturedPublicIngestIo(io: KtxCliIo): io is CapturedPublicIngestIo {
|
||||
return typeof (io as Partial<CapturedPublicIngestIo>).capturedOutput === 'function';
|
||||
}
|
||||
|
||||
const PLAIN_PUBLIC_INGEST_PHASE_LABELS: Record<KtxPublicIngestPhaseKey, string> = {
|
||||
'database-schema': 'database schema',
|
||||
'query-history': 'query history',
|
||||
'source-ingest': 'source ingest',
|
||||
};
|
||||
|
||||
interface PlainPublicIngestProgressOptions {
|
||||
target: KtxPublicIngestPlanTarget;
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function firstSummaryLine(summary: string | undefined): string | undefined {
|
||||
if (!summary) return undefined;
|
||||
return summary.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim();
|
||||
}
|
||||
|
||||
function plainPhaseHeader(options: PlainPublicIngestProgressOptions, phaseKey: KtxPublicIngestPhaseKey): string {
|
||||
const prefix = options.total > 1 ? `[${options.index + 1}/${options.total}] ` : '';
|
||||
return `${prefix}${options.target.connectionId} · ${PLAIN_PUBLIC_INGEST_PHASE_LABELS[phaseKey]}`;
|
||||
}
|
||||
|
||||
function plainPhaseEndLine(status: 'done' | 'failed' | 'skipped', summary?: string): string {
|
||||
const firstLine = firstSummaryLine(summary);
|
||||
return firstLine ? ` ${status} · ${firstLine}` : ` ${status}`;
|
||||
}
|
||||
|
||||
function createPlainPublicIngestProgress(io: KtxCliIo, options: PlainPublicIngestProgressOptions): Required<
|
||||
Pick<KtxPublicIngestDeps, 'scanProgress' | 'ingestProgress' | 'onPhaseStart' | 'onPhaseEnd'>
|
||||
> {
|
||||
let currentPhase: KtxPublicIngestPhaseKey | null = null;
|
||||
const startedPhases = new Set<KtxPublicIngestPhaseKey>();
|
||||
const lastPercentByPhase = new Map<KtxPublicIngestPhaseKey, number>();
|
||||
|
||||
const startPhase = (phaseKey: KtxPublicIngestPhaseKey): void => {
|
||||
currentPhase = phaseKey;
|
||||
startedPhases.add(phaseKey);
|
||||
lastPercentByPhase.set(phaseKey, -1);
|
||||
io.stderr.write(`${plainPhaseHeader(options, phaseKey)}\n`);
|
||||
};
|
||||
|
||||
const ensurePhaseStarted = (phaseKey: KtxPublicIngestPhaseKey): void => {
|
||||
if (!startedPhases.has(phaseKey)) {
|
||||
startPhase(phaseKey);
|
||||
return;
|
||||
}
|
||||
currentPhase = phaseKey;
|
||||
};
|
||||
|
||||
const emitProgress = (update: KtxIngestProgressUpdate): void => {
|
||||
if (currentPhase === null) return;
|
||||
const rounded = Math.max(0, Math.min(100, Math.round(update.percent)));
|
||||
const lastPercent = lastPercentByPhase.get(currentPhase) ?? -1;
|
||||
if (rounded <= lastPercent) return;
|
||||
lastPercentByPhase.set(currentPhase, rounded);
|
||||
io.stderr.write(` [${rounded}%] ${publicProgressMessage(update.message, options.target)}\n`);
|
||||
};
|
||||
|
||||
return {
|
||||
onPhaseStart: startPhase,
|
||||
onPhaseEnd(phaseKey, status, summary) {
|
||||
ensurePhaseStarted(phaseKey);
|
||||
io.stderr.write(`${plainPhaseEndLine(status, summary)}\n`);
|
||||
currentPhase = null;
|
||||
},
|
||||
scanProgress: createAggregateProgressPort(emitProgress),
|
||||
ingestProgress: emitProgress,
|
||||
};
|
||||
}
|
||||
|
||||
const INTERNAL_STATUS_LINE_RE =
|
||||
/^(Report|Run|Job|Status|Adapter|Connection|Sync|Diff|Tasks|Work units|Failed tasks|Saved memory|Provenance rows):\s*/;
|
||||
const ACTIONABLE_FAILURE_LINE_RE =
|
||||
|
|
@ -767,11 +910,35 @@ function capturedFailureMessage(output: string): string | undefined {
|
|||
return [firstLine, ...followupLines].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one ingest target through its scan/ingest steps. The single per-target
|
||||
* chokepoint reached by every entrypoint — standalone `ktx ingest` (plain/json
|
||||
* and foreground) and `ktx setup` (via `runContextBuild`). The exported
|
||||
* `executePublicIngestTarget` wraps this and emits the `ingest_completed`
|
||||
* telemetry event exactly once, so every path is counted.
|
||||
*/
|
||||
export async function executePublicIngestTarget(
|
||||
target: KtxPublicIngestPlanTarget,
|
||||
args: Extract<KtxPublicIngestArgs, { command: 'run' }>,
|
||||
io: KtxCliIo,
|
||||
deps: KtxPublicIngestDeps,
|
||||
project: KtxPublicIngestProject,
|
||||
): Promise<KtxPublicIngestTargetResult> {
|
||||
const startedAt = performance.now();
|
||||
const result = await runIngestTargetSteps(target, args, io, deps, project);
|
||||
// `io` may be a capture buffer for the scan/ingest step output; the telemetry
|
||||
// debug echo belongs on the real user-facing stream, which callers expose as
|
||||
// `deps.runtimeIo` (falling back to `io` when the step io is already real).
|
||||
await emitIngestCompleted({ args, project, target, result, startedAt, io: deps.runtimeIo ?? io });
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runIngestTargetSteps(
|
||||
target: KtxPublicIngestPlanTarget,
|
||||
args: Extract<KtxPublicIngestArgs, { command: 'run' }>,
|
||||
io: KtxCliIo,
|
||||
deps: KtxPublicIngestDeps,
|
||||
project: KtxPublicIngestProject,
|
||||
): Promise<KtxPublicIngestTargetResult> {
|
||||
if (target.preflightFailure) {
|
||||
if (target.operation === 'database-ingest') {
|
||||
|
|
@ -790,7 +957,7 @@ export async function executePublicIngestTarget(
|
|||
? {
|
||||
...step,
|
||||
status: 'failed',
|
||||
detail: target.preflightFailure,
|
||||
detail: `${target.connectionId} failed: ${target.preflightFailure}`,
|
||||
}
|
||||
: step,
|
||||
),
|
||||
|
|
@ -810,7 +977,11 @@ export async function executePublicIngestTarget(
|
|||
...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
|
||||
};
|
||||
const runScan = deps.runScan ?? runKtxScan;
|
||||
const capturedScanIo = deps.scanProgress ? null : createCapturedPublicIngestIo();
|
||||
const capturedScanIo = deps.scanProgress
|
||||
? isCapturedPublicIngestIo(io)
|
||||
? io
|
||||
: null
|
||||
: createCapturedPublicIngestIo();
|
||||
const scanIo = capturedScanIo ?? io;
|
||||
const scanDeps = {
|
||||
...(deps.scanProgress ? { progress: deps.scanProgress } : {}),
|
||||
|
|
@ -837,6 +1008,11 @@ export async function executePublicIngestTarget(
|
|||
if (target.queryHistory?.enabled === true) {
|
||||
const { runKtxIngest } = await import('./ingest.js');
|
||||
const runIngest = deps.runIngest ?? runKtxIngest;
|
||||
const historicSqlPullConfigOverride =
|
||||
(await resolvedQueryHistoryPullConfigForTarget(target, project)) ?? {
|
||||
dialect: target.queryHistory.dialect,
|
||||
...(target.queryHistory.windowDays !== undefined ? { windowDays: target.queryHistory.windowDays } : {}),
|
||||
};
|
||||
const ingestArgs: KtxIngestArgs = {
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
|
|
@ -847,13 +1023,15 @@ export async function executePublicIngestTarget(
|
|||
...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
|
||||
...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),
|
||||
allowImplicitAdapter: true,
|
||||
historicSqlPullConfigOverride:
|
||||
target.queryHistory.pullConfig ?? {
|
||||
dialect: target.queryHistory.dialect,
|
||||
...(target.queryHistory.windowDays !== undefined ? { windowDays: target.queryHistory.windowDays } : {}),
|
||||
},
|
||||
historicSqlPullConfigOverride,
|
||||
};
|
||||
const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo();
|
||||
// Query history runs after the schema scan has already written its report
|
||||
// into the shared target io, so it needs a phase-local capture. Reusing
|
||||
// `io` here would let leftover scan text (e.g. "Mode: enriched") surface as
|
||||
// the query-history failure detail. Only skip capture when progress is
|
||||
// active and the caller manages its own buffer (io is not a capture).
|
||||
const capturedIngestIo =
|
||||
deps.ingestProgress && !isCapturedPublicIngestIo(io) ? null : createCapturedPublicIngestIo();
|
||||
const ingestIo = capturedIngestIo ?? io;
|
||||
const ingestDeps = {
|
||||
...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}),
|
||||
|
|
@ -893,7 +1071,11 @@ export async function executePublicIngestTarget(
|
|||
allowImplicitAdapter: true,
|
||||
};
|
||||
const runIngest = deps.runIngest ?? runKtxIngest;
|
||||
const capturedIngestIo = deps.ingestProgress ? null : createCapturedPublicIngestIo();
|
||||
const capturedIngestIo = deps.ingestProgress
|
||||
? isCapturedPublicIngestIo(io)
|
||||
? io
|
||||
: null
|
||||
: createCapturedPublicIngestIo();
|
||||
const ingestIo = capturedIngestIo ?? io;
|
||||
const ingestDeps = {
|
||||
...(deps.ingestProgress ? { progress: deps.ingestProgress } : {}),
|
||||
|
|
@ -938,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);
|
||||
|
|
@ -976,11 +1191,27 @@ export async function runKtxPublicIngest(
|
|||
}
|
||||
}
|
||||
|
||||
for (const target of plan.targets) {
|
||||
const startedAt = performance.now();
|
||||
const result = await executePublicIngestTarget(target, args, io, deps);
|
||||
results.push(result);
|
||||
await emitIngestCompleted({ args, project, target, result, startedAt, io });
|
||||
for (const [index, target] of plan.targets.entries()) {
|
||||
if (args.json) {
|
||||
results.push(await executePublicIngestTarget(target, args, io, deps, project));
|
||||
continue;
|
||||
}
|
||||
|
||||
const capture = createCapturedPublicIngestIo();
|
||||
const progress = createPlainPublicIngestProgress(io, {
|
||||
target,
|
||||
index,
|
||||
total: plan.targets.length,
|
||||
});
|
||||
const targetDeps: KtxPublicIngestDeps = {
|
||||
...deps,
|
||||
scanProgress: progress.scanProgress,
|
||||
ingestProgress: progress.ingestProgress,
|
||||
onPhaseStart: progress.onPhaseStart,
|
||||
onPhaseEnd: progress.onPhaseEnd,
|
||||
runtimeIo: deps.runtimeIo ?? io,
|
||||
};
|
||||
results.push(await executePublicIngestTarget(target, args, capture, targetDeps, project));
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
|
|
|
|||
93
packages/cli/src/reveal-password-prompt.ts
Normal file
93
packages/cli/src/reveal-password-prompt.ts
Normal file
|
|
@ -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<PasswordPrompt, 'prompt'>, 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<string | symbol> {
|
||||
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<string | symbol>;
|
||||
}
|
||||
|
|
@ -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,8 +8,9 @@ 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 { scrubErrorClass } from './telemetry/scrubber.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<number> {
|
||||
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',
|
||||
|
|
@ -380,6 +382,7 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
|
|||
return 0;
|
||||
} catch (error) {
|
||||
const errorClass = scrubErrorClass(error);
|
||||
const errorDetail = formatErrorDetail(error);
|
||||
await emitTelemetryEvent({
|
||||
name: 'scan_completed',
|
||||
projectDir: args.projectDir,
|
||||
|
|
@ -393,8 +396,23 @@ export async function runKtxScan(args: KtxScanArgs, io: KtxCliIo = process, deps
|
|||
durationMs: Math.max(0, performance.now() - startedAt),
|
||||
outcome: 'error',
|
||||
...(errorClass ? { errorClass } : {}),
|
||||
...(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;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue