Merge branch 'main' into andreybavt/execute-context7-plan

This commit is contained in:
Andrey Avtomonov 2026-05-12 13:04:16 +02:00 committed by GitHub
commit 15f433930e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1385 additions and 173 deletions

1
.gitignore vendored
View file

@ -43,6 +43,7 @@ yarn-error.log*
# Local project runtime state
.ktx/
**/.devtools/
*.db
*.sqlite
*.sqlite3

114
README.md
View file

@ -1,5 +1,5 @@
<h1 align="center">
<img src="assets/ktx-readme-header.png" alt="KTX" width="472" />
<img src="assets/ktx-lockup.svg" alt="KTX" width="500" />
</h1>
<p align="center">
@ -43,6 +43,7 @@ SQLite.
Install the CLI and run the setup wizard:
```bash
npm install @kaelio/ktx
npm install -g @kaelio/ktx
ktx setup
```
@ -70,6 +71,40 @@ KTX context built: yes
Agent integration ready: yes (claude-code:project)
```
Run the packaged demo without installing globally:
```bash
npx @kaelio/ktx setup demo --no-input
npx @kaelio/ktx setup demo inspect
```
The default demo uses packaged sample data and prebuilt context. It does not
require API keys, network access, or an LLM provider.
Generate SQL from a semantic-layer source:
```bash
npx @kaelio/ktx sl query --project-dir "$PROJECT_DIR" \
--connection-id warehouse \
--measure accounts.account_count \
--dimension accounts.segment \
--format sql
```
List and test a configured warehouse connection:
```bash
ktx connection list --project-dir "$PROJECT_DIR"
ktx connection test warehouse --project-dir "$PROJECT_DIR"
```
The connection test prints the configured driver and discovered table count:
```text
Driver: sqlite
Tables: 1
```
## What's in a project
```
@ -97,6 +132,47 @@ Semantic sources and knowledge pages are committed to git. The `.ktx/` directory
holds ephemeral state and is git-ignored — delete it and KTX rebuilds on the
next run.
### Scan the demo warehouse
Scan artifacts are written under
`raw-sources/warehouse/live-database/<syncId>/` in the project directory.
```bash
SCAN_OUTPUT="$(ktx scan warehouse --project-dir "$PROJECT_DIR")"
printf '%s\n' "$SCAN_OUTPUT"
SCAN_RUN_ID="$(printf '%s\n' "$SCAN_OUTPUT" | awk '/^Run: / { print $2 }')"
ktx scan status --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
ktx scan report --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
```
For non-SQLite drivers, prefer credential references such as `--url env:NAME`
or `--url file:PATH` over literal credential URLs.
## Managed Python runtime
KTX installs its Python runtime only when a Python-backed command needs it.
The runtime lives outside the npm cache, is versioned by the installed CLI
version, and is managed by `ktx runtime` commands.
KTX requires `uv` on `PATH` to create the managed runtime. Install `uv` with
your system package manager or the official installer before running Python-
backed KTX commands. KTX doesn't download `uv` automatically; run
`ktx runtime doctor` if runtime installation fails:
```bash
ktx runtime install --yes
ktx runtime status
ktx runtime doctor
ktx runtime start
ktx runtime stop
ktx runtime prune --dry-run
ktx runtime prune --yes
```
The release artifact manifest contains the public npm tarball and the bundled `kaelio-ktx`
runtime wheel. The `python/ktx-sl` and `python/ktx-daemon` directories remain
source packages for development, not public release artifacts.
## Serve agents
KTX integrates with coding agents through CLI skills, an MCP server, or both.
@ -126,6 +202,11 @@ This exposes tools for connections, knowledge search, semantic-layer sources,
validation, queries, ingestion, and replay. The `--semantic-compute` flag starts
the managed Python runtime for query planning automatically.
The standalone MCP server exposes `connection_list`, `knowledge_search`,
`knowledge_read`, `knowledge_write`, `sl_list_sources`, `sl_read_source`,
`sl_write_source`, `sl_validate`, `sl_query`, `ingest_trigger`,
`ingest_status`, `ingest_report`, and `ingest_replay`.
Supported agents: Claude Code, Codex, Cursor, OpenCode, and any agent that
reads `.agents/` skills or MCP configuration.
@ -136,7 +217,13 @@ reads `.agents/` skills or MCP configuration.
| `packages/cli` | CLI entry point |
| `packages/context` | Core context engine |
| `packages/llm` | LLM and embedding providers |
| `packages/connector-*` | Database connectors (Postgres, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server, SQLite) |
| `packages/connector-bigquery` | BigQuery scan connector |
| `packages/connector-clickhouse` | ClickHouse scan connector |
| `packages/connector-mysql` | MySQL scan connector |
| `packages/connector-postgres` | Postgres scan connector |
| `packages/connector-snowflake` | Snowflake scan connector |
| `packages/connector-sqlite` | SQLite scan connector |
| `packages/connector-sqlserver` | SQL Server scan connector |
| `python/ktx-sl` | Semantic-layer query planning |
| `python/ktx-daemon` | Portable compute service |
@ -159,6 +246,29 @@ pnpm run link:dev
ktx-dev --help
```
### Debug LLM traces
KTX can capture local AI SDK DevTools traces for LLM calls that run through the
KTX provider. Enable it with an environment flag when running an LLM-backed
command:
```bash
KTX_AI_DEVTOOLS_ENABLED=true ktx dev ingest run \
--connection-id warehouse \
--adapter metabase
```
Traces are written to `.devtools/generations.json` under the current working
directory. To inspect them, run:
```bash
pnpm dlx @ai-sdk/devtools
```
Then open `http://localhost:4983`. These traces are local-development-only and
store prompts, model outputs, tool arguments/results, and raw provider payloads
in plain text. Do not enable this in production or for sensitive runs.
The repository uses `pnpm` for TypeScript packages and `uv` for Python
packages. See [Contributing](docs-site/content/docs/community/contributing.mdx)
for full development setup, testing, and PR guidelines.

32
assets/ktx-lockup.svg Normal file
View file

@ -0,0 +1,32 @@
<svg viewBox="0 0 500 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ktx">
<!-- mascot -->
<g fill="none" stroke="#1B3139" stroke-width="16" stroke-linecap="round">
<path d="M 62 110 Q 32 130 44 152" />
<path d="M 88 116 Q 80 152 70 174" />
<path d="M 112 116 Q 120 152 130 174" />
</g>
<path
d="M 134 108 C 162 116, 172 96, 162 78 C 154 64, 168 56, 178 60"
fill="none" stroke="#FF8A4C" stroke-width="16" stroke-linecap="round"
/>
<path
d="M 48 102 C 48 56, 78 30, 100 30 C 122 30, 152 56, 152 102 C 152 116, 132 120, 100 120 C 68 120, 48 116, 48 102 Z"
fill="#1B3139"
/>
<path d="M 80 84 Q 86 77 92 84" fill="none" stroke="#F5F1EA" stroke-width="3.5" stroke-linecap="round" />
<path d="M 108 84 Q 114 77 120 84" fill="none" stroke="#F5F1EA" stroke-width="3.5" stroke-linecap="round" />
<!-- wordmark: 'ktx', half the logo height, vertically centered -->
<text
x="225"
y="145"
font-family="'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, monospace"
font-size="140"
font-weight="600"
fill="#1B3139"
letter-spacing="-0.04em"
>ktx</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View file

@ -213,7 +213,7 @@ For multiple datasets:
| Method | Config |
|--------|--------|
| Service account JSON | `credentials_json: file:/path/to/key.json` |
| Environment variable | `credentials_json: env:GCP_CREDENTIALS_JSON` |
| Environment variable | `credentials_json: env:BIGQUERY_CREDENTIALS_JSON` |
The project ID is extracted automatically from the service account JSON file.

View file

@ -29,5 +29,5 @@ examples/orbit-relationship-verification/reports/orbit-verification.md
Use a real local Orbit project by overriding the project directory:
```bash
KTX_ORBIT_PROJECT_DIR=/path/to/orbit-project pnpm run relationships:verify-orbit
KTX_PROJECT_DIR=/path/to/orbit-project pnpm run relationships:verify-orbit
```

View file

@ -12,7 +12,7 @@ refs:
## New Hire Week-One Onboarding Policy
**Source:** Notion — People & Operating Norms, last edited 2026-05-07
**Source:** Notion — People & Operating Norms, last edited 2026-05-07
**Owner:** Manager (not People Ops)
---

View file

@ -20,7 +20,7 @@ tables:
# Activation KPI Glossary
**Owner team:** Growth
**Owner team:** Growth
**Source:** Notion — Orbit Demo Home / Data Team - Onboarding / Activation KPI Glossary, last edited 2026-05-07
Use this when a question is about signup-to-habit behavior. Orbit uses activation language across Growth, Product, and CS conversations.
@ -62,4 +62,3 @@ Growth conversations typically use D7 and D14 Activation Rate. Product and CS ma
## Relationship to Account-Level Activation
This glossary defines **customer-level** activation (signup-to-habit). The **account-level** activation workflow (requester login → first approved purchase request → account activated) is a separate concept tracked in `mart_account_activity` and governed by the January 2026 policy change. See `orbit-activation-policy-change-jan-2026` for that definition.

View file

@ -14,9 +14,9 @@ sl_refs:
# Activation Policy Change — January 2026
**Governed metric key:** `activated_accounts`
**Owner team:** growth
**Notion:** `notion://notion_page_activation_policy_decision#policy-change`
**Governed metric key:** `activated_accounts`
**Owner team:** growth
**Notion:** `notion://notion_page_activation_policy_decision#policy-change`
**Sources:** `mart_account_activity`, `int_activation_policy_windows`, `stg_activation_events`
## Policy Boundary

View file

@ -15,9 +15,9 @@ sl_refs:
# ARR — Contract-First Definition
**Governed metric key:** `arr`
**Owner team:** finance
**Notion:** `notion://notion_page_arr_contract_reporting#arr-contract-first`
**Governed metric key:** `arr`
**Owner team:** finance
**Notion:** `notion://notion_page_arr_contract_reporting#arr-contract-first`
**Source:** `mart_arr_daily` (grain: `metric_date`)
## Rule

View file

@ -21,7 +21,7 @@ refs:
Orbit sells procurement workflow and spend-control software. The core value proposition: route purchase requests, collect approvals, onboard suppliers, and issue purchase orders without turning every exception into a status hunt.
**Primary buyers:** Finance, Procurement, Business Operations.
**Primary buyers:** Finance, Procurement, Business Operations.
**Daily users:** department admins, office managers, IT leads, legal ops partners — anyone who has to get a vendor through the building.
## Product Workflow
@ -69,4 +69,3 @@ Orbit sells procurement workflow and spend-control software. The core value prop
- "Supplier onboarding is split across three teams."
- "Renewals are visible too late."
- "People keep asking Finance for status because there is nowhere better to look."

View file

@ -14,9 +14,9 @@ sl_refs:
# Customer Health Risk Definition
**Governed metric key:** `active_customers`
**Owner team:** customer_success
**Notion:** `notion://notion_page_customer_health_playbook#risk-definition`
**Governed metric key:** `active_customers`
**Owner team:** customer_success
**Notion:** `notion://notion_page_customer_health_playbook#risk-definition`
**Sources:** `mart_customer_health`, `int_customer_health_signals`
## Risk Levels

View file

@ -18,8 +18,8 @@ tables:
# Orbit Customers Source
**Table:** `orbit_analytics.customer`
**Grain:** one row per signed-up customer
**Table:** `orbit_analytics.customer`
**Grain:** one row per signed-up customer
**Source:** Notion — Orbit Demo Home / Data Team - Onboarding / Orbit Customers Source, last edited 2026-05-07
Use this when a question needs customer identity, plan tier, signup timing, recent activity, or the standard customer joins.
@ -58,4 +58,3 @@ Always join through `customer.id`. Do not join on `email`.
- **Timezone:** `created_at` and `last_seen_at` are UTC. Confirm whether a question expects UTC or a local business day before filtering.
- **Paying vs. all:** `free` customers must be excluded from paying-customer follow-ups. Use `paying_customer_count`, not `customer_count`.
- **plan_tier values:** `free`, `pro`, `enterprise`. Note: `pro_plus` is a legacy alias for `growth` in the account/contract layer (see `orbit-plan-segment-normalization`), but `plan_tier` on this table uses `pro` not `pro_plus`.

View file

@ -42,4 +42,3 @@ Declared in `models/exposures.yml`. All exposures are type `dashboard` with matu
- **Owner:** Growth (growth@orbit-demo.example.com)
- **Depends on:** `mart_account_activity`
- **Description:** Activation policy comparison around the January 2026 workflow update.

View file

@ -22,10 +22,10 @@ sl_refs:
# Orbit dbt Project Overview
**Project name:** `kaelio_demo`
**dbt version:** 1.0.0
**Profile target:** Postgres (`orbit_analytics` schema, `kaelio_demo` database)
**Raw source schema:** `orbit_raw`
**Project name:** `kaelio_demo`
**dbt version:** 1.0.0
**Profile target:** Postgres (`orbit_analytics` schema, `kaelio_demo` database)
**Raw source schema:** `orbit_raw`
**Analytics schema:** `orbit_analytics` (all models materialised as views by default)
## Model Layers
@ -52,4 +52,3 @@ sl_refs:
## Raw Source Tables (`orbit_raw` schema)
accounts, account_hierarchy, plans, contracts, subscriptions, contract_discount_terms, arr_movements, invoices, invoice_line_items, refunds, plan_segment_mapping, users, activation_events, sessions, purchase_requests, approval_events, suppliers, supplier_onboarding_events, purchase_orders, support_tickets, account_owners.

View file

@ -20,7 +20,7 @@ tables:
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/106.json -->
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/107.json -->
**Table:** `orbit_analytics.mart_account_activity`
**Table:** `orbit_analytics.mart_account_activity`
**Grain:** one row per `policy_change_date`
## Columns
@ -47,4 +47,3 @@ tables:
- The January 2026 activation policy change (`policy_change_date = 2026-01-15`) is the primary boundary. `policy_version` in upstream events splits into `pre_2026_01_15` and `post_2026_01_15` cohorts.
- Rates are ratios (01); multiply by 100 for percentage display.
- See [orbit-activation-policy-change-jan-2026](orbit-activation-policy-change-jan-2026) for full policy context.

View file

@ -19,7 +19,7 @@ tables:
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/69.json -->
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/100.json -->
**Table:** `orbit_analytics.mart_account_segments`
**Table:** `orbit_analytics.mart_account_segments`
**Grain:** one row per `account_id`
## Columns
@ -53,4 +53,3 @@ tables:
- `normalized_plan_code` maps `pro_plus``growth`. Always use `normalized_plan_code` for plan-based reporting. See [orbit-plan-segment-normalization](orbit-plan-segment-normalization).
- `segment` is derived from `canonical_plan_code × size_band` via `stg_plan_segment_mapping`.
- `contract_arr_cents` is the contract-first ARR value. See [orbit-arr-contract-first-definition](orbit-arr-contract-first-definition).

View file

@ -18,7 +18,7 @@ tables:
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/56.json -->
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/96.json -->
**Table:** `orbit_analytics.mart_arr_daily`
**Table:** `orbit_analytics.mart_arr_daily`
**Grain:** one row per `metric_date`
## Columns
@ -44,4 +44,3 @@ tables:
- ARR is calculated contract-first: active contract ARR takes precedence over subscription ARR for any covered period. See [orbit-arr-contract-first-definition](orbit-arr-contract-first-definition).
- `display` is a formatted label for UI rendering; use `arr_cents` for all arithmetic.

View file

@ -20,7 +20,7 @@ tables:
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/98.json -->
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/103.json -->
**Table:** `orbit_analytics.mart_nrr_quarterly`
**Table:** `orbit_analytics.mart_nrr_quarterly`
**Grain:** one row per `quarter_label` × `segment`
## Columns
@ -53,4 +53,3 @@ tables:
- `net_revenue_retention` is a ratio, not a percentage. Multiply by 100 for display.
- Contraction includes discount expirations (classified as contraction, not churn). See [orbit-nrr-discount-expiration-treatment](orbit-nrr-discount-expiration-treatment).
- Enterprise is the primary executive reporting segment.

View file

@ -18,7 +18,7 @@ tables:
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/88.json -->
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/108.json -->
**Table:** `orbit_analytics.mart_procurement_activity`
**Table:** `orbit_analytics.mart_procurement_activity`
**Grain:** one row per `week_start_date` × `contract_arr_threshold_cents`
## Columns
@ -45,4 +45,3 @@ tables:
- `active_requesters` counts non-internal, non-test requesters on large active contracts. See [orbit-procurement-qualifying-actions](orbit-procurement-qualifying-actions).
- The standard threshold is `contract_arr_threshold_cents = 20000000` ($200k ARR).
- Always filter by `contract_arr_threshold_cents` — the table contains rows for multiple threshold values.

View file

@ -19,7 +19,7 @@ tables:
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/105.json -->
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/115.json -->
**Table:** `orbit_analytics.mart_retention_movement_breakout`
**Table:** `orbit_analytics.mart_retention_movement_breakout`
**Grain:** one row per `quarter_label` × `segment` × `movement_type` × `movement_reason`
## Columns
@ -53,4 +53,3 @@ tables:
- Contraction includes discount expirations, classified as contraction (not churn), tracked via `movement_reason`. See [orbit-nrr-discount-expiration-treatment](orbit-nrr-discount-expiration-treatment).
- This table is the row-level source for `mart_nrr_quarterly` aggregations.
- Only one of `expansion_arr_cents`, `contraction_arr_cents`, `churned_arr_cents` is non-zero per row.

View file

@ -20,7 +20,7 @@ tables:
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/102.json -->
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/104.json -->
**Table:** `orbit_analytics.mart_revenue_daily`
**Table:** `orbit_analytics.mart_revenue_daily`
**Grain:** one row per `revenue_date`
## Columns
@ -54,4 +54,3 @@ tables:
- `reconciliation_check` must be `true` on every row. Any `false` row indicates a data quality issue.
- Gross-to-net reconciliation: gross revenue credits refunds = net revenue. See [orbit-revenue-gross-to-net-reconciliation](orbit-revenue-gross-to-net-reconciliation).
- All amounts are in cents; divide by 100 for USD, by 100,000,000 for $M.

View file

@ -69,4 +69,3 @@ Card 48 is the canonical reference; card 55 is a filtered variant for large-cont
| 53 | Enterprise NRR quarter breakout | mart_nrr_quarterly | 0 |
| 54 | February credits drilldown | mart_revenue_daily | 0 |
| 55 | Large contract requesters | mart_account_segments | 0 |

View file

@ -15,9 +15,9 @@ sl_refs:
# NRR — Discount Expiration Treatment
**Governed metric key:** `net_revenue_retention`
**Owner team:** analytics
**Notion:** `notion://notion_page_retention_policy_current#nrr-definition` and `#discount-expiration-treatment`
**Governed metric key:** `net_revenue_retention`
**Owner team:** analytics
**Notion:** `notion://notion_page_retention_policy_current#nrr-definition` and `#discount-expiration-treatment`
**Sources:** `mart_nrr_quarterly`, `mart_retention_movement_breakout`
## NRR Definition

View file

@ -14,9 +14,9 @@ sl_refs:
# Plan & Segment Normalization
**Governed metric key:** `segment`
**Owner team:** sales_ops
**Notion:** `notion://notion_page_sales_ops_segmentation#growth-plan-normalization`
**Governed metric key:** `segment`
**Owner team:** sales_ops
**Notion:** `notion://notion_page_sales_ops_segmentation#growth-plan-normalization`
**Sources:** `mart_account_segments`, `stg_plan_segment_mapping`, `stg_plans`
## Canonical Plan Codes

View file

@ -14,9 +14,9 @@ sl_refs:
# Procurement — Qualifying Actions & Weekly Active Requesters
**Governed metric key:** `weekly_active_requesters`
**Owner team:** product
**Notion:** `notion://notion_page_procurement_instrumentation#qualifying-procurement-actions`
**Governed metric key:** `weekly_active_requesters`
**Owner team:** product
**Notion:** `notion://notion_page_procurement_instrumentation#qualifying-procurement-actions`
**Sources:** `mart_procurement_activity`, `int_procurement_qualifying_actions`
## Qualifying Action Definition

View file

@ -14,9 +14,9 @@ sl_refs:
# Revenue — Gross-to-Net Reconciliation
**Governed metric key:** `net_revenue`
**Owner team:** finance
**Notion:** `notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation`
**Governed metric key:** `net_revenue`
**Owner team:** finance
**Notion:** `notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation`
**Source:** `mart_revenue_daily` (grain: `revenue_date`)
## Formula

View file

@ -14,7 +14,7 @@ refs:
## Sales Ops → Customer Success Implementation Handoff
**Source:** Notion — People & Operating Norms, last edited 2026-05-07
**Source:** Notion — People & Operating Norms, last edited 2026-05-07
**Owner:** Sales Ops (sender), Customer Success (receiver)
---

View file

@ -138,7 +138,7 @@ function makeIo(options: { isTTY?: boolean; stdinIsTTY?: boolean } = {}) {
describe('runKtxConnectionMetabaseSetup', () => {
const fakeMetabaseCredential = 'mb_example';
const existingMetabaseCredential = 'mb_existing';
const fakeAdminCredential = 'pw';
const fakeAdminCredential = 'admin-secret-value-123';
let tempDir: string;
let projectDir: string;

View file

@ -53,10 +53,12 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
runtime
.command('stop')
.description('Stop the KTX-managed Python HTTP daemon')
.action(async () => {
.option('--all', 'Stop all KTX daemon processes recorded or discoverable on this machine', false)
.action(async (options: { all?: boolean }) => {
await runRuntimeArgs(context, {
command: 'stop',
cliVersion: context.packageInfo.version,
all: options.all === true,
});
});

View file

@ -477,7 +477,7 @@ describe('runKtxConnection', () => {
force: false,
allowLiteralCredentials: false,
notion: {
authTokenRef: 'env:NOTION_AUTH_TOKEN',
authTokenRef: 'env:NOTION_TOKEN',
crawlMode: 'all_accessible',
rootPageIds: [],
rootDatabaseIds: [],
@ -493,7 +493,7 @@ describe('runKtxConnection', () => {
const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(yaml).toContain('driver: notion');
expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN');
expect(yaml).toContain('auth_token_ref: env:NOTION_TOKEN');
expect(yaml).toContain('crawl_mode: all_accessible');
expect(yaml).toContain('max_pages_per_run: 50');
expect(yaml).not.toContain('ntn_');
@ -516,7 +516,7 @@ describe('runKtxConnection', () => {
force: false,
allowLiteralCredentials: false,
notion: {
authTokenRef: 'env:NOTION_AUTH_TOKEN',
authTokenRef: 'env:NOTION_TOKEN',
crawlMode: 'all_accessible',
rootPageIds: [],
rootDatabaseIds: ['database-1'],

View file

@ -11,6 +11,9 @@ import type { renderMemoryFlowTui } from './memory-flow-tui.js';
import { KTX_NEXT_STEP_COMMANDS } from './next-steps.js';
import { resetVizFallbackWarningsForTest } from './viz-fallback.js';
const SEEDED_DEMO_SEMANTIC_SOURCE_COUNT = 46;
const SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT = 28;
function makeIo(options: { isTTY?: boolean; columns?: number; rawMode?: boolean } = {}) {
let stdout = '';
let stderr = '';
@ -336,8 +339,14 @@ describe('runKtxDemo', () => {
notion: { pageCount: 8 },
},
generatedOutputs: {
semanticLayer: { manifestSourceCount: 6, fileCount: 6 },
knowledge: { manifestPageCount: 10, fileCount: 10 },
semanticLayer: {
manifestSourceCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
fileCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
},
knowledge: {
manifestPageCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
fileCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
},
links: { manifestLinkCount: 23, linkCount: 23 },
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
},
@ -636,10 +645,16 @@ describe('runKtxDemo', () => {
).resolves.toBe(0);
expect(seededIo.stdout()).toContain('Status: ready');
expect(seededIo.stdout()).toContain('Semantic-layer sources: 6 manifest, 6 files');
expect(seededIo.stdout()).toContain('Knowledge pages: 10 manifest, 10 files');
expect(seededIo.stdout()).toContain(
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} files`,
);
expect(seededIo.stdout()).toContain(
`Knowledge pages: ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} manifest, ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} files`,
);
expect(seededIo.stdout()).not.toContain('Status: corrupt');
expect(seededIo.stdout()).not.toContain('Semantic-layer sources: 6 manifest, 0 files');
expect(seededIo.stdout()).not.toContain(
`Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, 0 files`,
);
});
it('fails corrupted demo projects in no-input mode with reset guidance', async () => {

View file

@ -144,6 +144,7 @@ describe('runKtxCli', () => {
const installIo = makeIo();
const startIo = makeIo();
const stopIo = makeIo();
const stopAllIo = makeIo();
const statusIo = makeIo();
const doctorIo = makeIo();
const pruneIo = makeIo();
@ -157,6 +158,7 @@ describe('runKtxCli', () => {
runKtxCli(['runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
@ -186,11 +188,21 @@ describe('runKtxCli', () => {
{
command: 'stop',
cliVersion: '0.0.0-private',
all: false,
},
stopIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
4,
{
command: 'stop',
cliVersion: '0.0.0-private',
all: true,
},
stopAllIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
5,
{
command: 'status',
cliVersion: '0.0.0-private',
@ -199,7 +211,7 @@ describe('runKtxCli', () => {
statusIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
5,
6,
{
command: 'doctor',
cliVersion: '0.0.0-private',
@ -208,7 +220,7 @@ describe('runKtxCli', () => {
doctorIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
6,
7,
{
command: 'prune',
cliVersion: '0.0.0-private',
@ -219,6 +231,17 @@ describe('runKtxCli', () => {
);
});
it('documents runtime stop all in command help', async () => {
const testIo = makeIo();
await expect(runKtxCli(['runtime', 'stop', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('--all');
expect(testIo.stdout()).toContain('Stop all KTX daemon processes recorded or discoverable');
expect(testIo.stdout()).toContain('on this machine');
expect(testIo.stderr()).toBe('');
});
it('routes sl query managed runtime install policies', async () => {
const sl = vi.fn(async () => 0);
@ -1982,7 +2005,7 @@ describe('runKtxCli', () => {
'--project-dir',
tempDir,
'--token-env',
'NOTION_AUTH_TOKEN',
'NOTION_TOKEN',
'--crawl-mode',
'selected_roots',
'--root-page-id',
@ -2009,7 +2032,7 @@ describe('runKtxCli', () => {
force: false,
allowLiteralCredentials: false,
notion: {
authTokenRef: 'env:NOTION_AUTH_TOKEN',
authTokenRef: 'env:NOTION_TOKEN',
crawlMode: 'selected_roots',
rootPageIds: ['page-1'],
rootDatabaseIds: ['database-1'],

View file

@ -47,13 +47,18 @@ export { runKtxRuntime, type KtxRuntimeArgs, type KtxRuntimeDeps } from './runti
export {
allocateDaemonPort,
readManagedPythonDaemonStatus,
stopAllManagedPythonDaemons,
startManagedPythonDaemon,
stopManagedPythonDaemon,
} from './managed-python-daemon.js';
export type {
ManagedPythonDaemonProcessInfo,
ManagedPythonDaemonStartResult,
ManagedPythonDaemonState,
ManagedPythonDaemonStatus,
ManagedPythonDaemonStopAllEntry,
ManagedPythonDaemonStopAllFailure,
ManagedPythonDaemonStopAllResult,
ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
export {

View file

@ -5,9 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
readManagedPythonDaemonStatus,
startManagedPythonDaemon,
stopAllManagedPythonDaemons,
stopManagedPythonDaemon,
type ManagedPythonDaemonChild,
type ManagedPythonDaemonFetch,
type ManagedPythonDaemonProcessInfo,
type ManagedPythonDaemonSpawn,
type ManagedPythonDaemonState,
} from './managed-python-daemon.js';
@ -105,6 +107,24 @@ function runningState(root: string, overrides: Partial<ManagedPythonDaemonState>
};
}
function daemonStatePath(root: string, version: string): string {
return join(root, 'runtime', version, 'daemon.json');
}
function runningStateForVersion(
root: string,
version: string,
overrides: Partial<ManagedPythonDaemonState> = {},
): ManagedPythonDaemonState {
return {
...runningState(root),
version,
stdoutLog: join(root, 'runtime', version, 'daemon.stdout.log'),
stderrLog: join(root, 'runtime', version, 'daemon.stderr.log'),
...overrides,
};
}
describe('managed Python daemon lifecycle', () => {
let tempDir: string;
@ -271,4 +291,138 @@ describe('managed Python daemon lifecycle', () => {
expect(killProcess).toHaveBeenCalledWith(4242);
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
});
it('stops all recorded daemon states across runtime versions and removes state files', async () => {
await mkdir(join(tempDir, 'runtime', '0.1.0'), { recursive: true });
await mkdir(join(tempDir, 'runtime', '0.2.0'), { recursive: true });
await writeFile(
daemonStatePath(tempDir, '0.1.0'),
`${JSON.stringify(runningStateForVersion(tempDir, '0.1.0', { pid: 1111, port: 61111 }), null, 2)}\n`,
);
await writeFile(
daemonStatePath(tempDir, '0.2.0'),
`${JSON.stringify(runningStateForVersion(tempDir, '0.2.0', { pid: 2222, port: 62222 }), null, 2)}\n`,
);
const alive = new Set([1111, 2222]);
const killProcess = vi.fn((pid: number) => {
alive.delete(pid);
});
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
listProcesses: vi.fn(async () => []),
processAlive: vi.fn((pid) => alive.has(pid)),
killProcess,
stopGraceMs: 0,
});
expect(result.failed).toHaveLength(0);
expect(result.stopped.map((entry) => entry.pid).sort()).toEqual([1111, 2222]);
expect(killProcess).toHaveBeenCalledWith(1111, 'SIGTERM');
expect(killProcess).toHaveBeenCalledWith(2222, 'SIGTERM');
await expect(readFile(daemonStatePath(tempDir, '0.1.0'), 'utf8')).rejects.toThrow();
await expect(readFile(daemonStatePath(tempDir, '0.2.0'), 'utf8')).rejects.toThrow();
});
it('removes stale state when the recorded daemon process is no longer alive', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
listProcesses: vi.fn(async () => []),
processAlive: vi.fn(() => false),
killProcess: vi.fn(),
stopGraceMs: 0,
});
expect(result.stopped).toHaveLength(0);
expect(result.stale.map((entry) => entry.pid)).toEqual([4242]);
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
});
it('deduplicates a daemon found by state and process scan, preferring state metadata', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
const alive = new Set([4242]);
const killProcess = vi.fn((pid: number) => {
alive.delete(pid);
});
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
listProcesses: vi.fn(async (): Promise<ManagedPythonDaemonProcessInfo[]> => [
{ pid: 4242, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 61234' },
]),
processAlive: vi.fn((pid) => alive.has(pid)),
killProcess,
stopGraceMs: 0,
});
expect(result.stopped).toHaveLength(1);
expect(result.stopped[0]).toMatchObject({
pid: 4242,
source: 'state',
url: 'http://127.0.0.1:58731',
});
expect(killProcess).toHaveBeenCalledTimes(1);
});
it('stops unrecorded ktx-daemon serve-http processes from process scan results', async () => {
const alive = new Set([3333, 5555]);
const killProcess = vi.fn((pid: number) => {
alive.delete(pid);
});
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
listProcesses: vi.fn(async (): Promise<ManagedPythonDaemonProcessInfo[]> => [
{ pid: 3333, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765' },
{ pid: 4444, command: 'node server.js --port 8765' },
{ pid: 5555, command: 'grep ktx-daemon serve-http --port 8765' },
]),
processAlive: vi.fn((pid) => alive.has(pid)),
killProcess,
stopGraceMs: 0,
});
expect(result.failed).toHaveLength(0);
expect(result.stopped).toEqual([
expect.objectContaining({
pid: 3333,
source: 'process',
url: 'http://127.0.0.1:8765',
}),
]);
expect(killProcess).toHaveBeenCalledWith(3333, 'SIGTERM');
expect(killProcess).not.toHaveBeenCalledWith(4444, expect.anything());
expect(killProcess).not.toHaveBeenCalledWith(5555, expect.anything());
});
it('reports a failed stop when TERM and KILL leave a daemon running', async () => {
await mkdir(layout(tempDir).versionDir, { recursive: true });
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
const result = await stopAllManagedPythonDaemons({
cliVersion: '0.2.0',
runtimeRoot: join(tempDir, 'runtime'),
listProcesses: vi.fn(async () => []),
processAlive: vi.fn(() => true),
killProcess: vi.fn(),
stopGraceMs: 0,
});
expect(result.stopped).toHaveLength(0);
expect(result.failed).toEqual([
expect.objectContaining({
pid: 4242,
detail: 'Process still running after SIGKILL',
}),
]);
expect(await readFile(layout(tempDir).daemonStatePath, 'utf8')).toContain('"pid": 4242');
});
});

View file

@ -1,7 +1,9 @@
import { spawn } from 'node:child_process';
import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
import { execFile, spawn } from 'node:child_process';
import { mkdir, open, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { promisify } from 'node:util';
import { z } from 'zod';
import {
installManagedPythonRuntime,
@ -44,6 +46,35 @@ export interface ManagedPythonDaemonStopResult {
state?: ManagedPythonDaemonState;
}
export interface ManagedPythonDaemonProcessInfo {
pid: number;
command: string;
}
export type ManagedPythonDaemonStopAllSource = 'state' | 'process';
export interface ManagedPythonDaemonStopAllEntry {
pid: number;
source: ManagedPythonDaemonStopAllSource;
url?: string;
health?: 'healthy' | 'unreachable';
version?: string;
command?: string;
statePaths: string[];
}
export interface ManagedPythonDaemonStopAllFailure extends ManagedPythonDaemonStopAllEntry {
detail: string;
}
export interface ManagedPythonDaemonStopAllResult {
runtimeRoot: string;
stopped: ManagedPythonDaemonStopAllEntry[];
stale: ManagedPythonDaemonStopAllEntry[];
failed: ManagedPythonDaemonStopAllFailure[];
scanErrors: string[];
}
export interface ManagedPythonDaemonChild {
pid?: number;
unref(): void;
@ -68,6 +99,8 @@ export type ManagedPythonDaemonFetch = (
text(): Promise<string>;
}>;
export type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void;
export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions {
features: KtxRuntimeFeature[];
force?: boolean;
@ -76,7 +109,7 @@ export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLay
fetch?: ManagedPythonDaemonFetch;
allocatePort?: () => Promise<number>;
processAlive?: (pid: number) => boolean;
killProcess?: (pid: number) => void;
killProcess?: ManagedPythonDaemonKillProcess;
now?: () => Date;
startupTimeoutMs?: number;
pollIntervalMs?: number;
@ -89,9 +122,20 @@ export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLa
export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions {
processAlive?: (pid: number) => boolean;
killProcess?: (pid: number) => void;
killProcess?: ManagedPythonDaemonKillProcess;
}
export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonRuntimeLayoutOptions {
listProcesses?: () => Promise<ManagedPythonDaemonProcessInfo[]>;
processAlive?: (pid: number) => boolean;
killProcess?: ManagedPythonDaemonKillProcess;
stopGraceMs?: number;
pollIntervalMs?: number;
healthProbeMs?: number;
}
const execFileAsync = promisify(execFile);
const daemonStateSchema = z.object({
schemaVersion: z.literal(1),
pid: z.number().int().positive(),
@ -126,9 +170,9 @@ function defaultProcessAlive(pid: number): boolean {
}
}
function defaultKillProcess(pid: number): void {
function defaultKillProcess(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void {
try {
process.kill(pid, 'SIGTERM');
process.kill(pid, signal);
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code !== 'ESRCH') {
@ -293,7 +337,7 @@ async function stopRecordedDaemon(input: {
layout: ManagedPythonRuntimeLayout;
state: ManagedPythonDaemonState;
processAlive: (pid: number) => boolean;
killProcess: (pid: number) => void;
killProcess: ManagedPythonDaemonKillProcess;
}): Promise<void> {
if (input.processAlive(input.state.pid)) {
input.killProcess(input.state.pid);
@ -301,6 +345,323 @@ async function stopRecordedDaemon(input: {
await removeState(input.layout);
}
function runtimeRootForStopAll(options: ManagedPythonRuntimeLayoutOptions): string {
return managedPythonRuntimeLayout(options).runtimeRoot;
}
async function removeStatePaths(paths: string[]): Promise<void> {
await Promise.all([...new Set(paths)].map((path) => rm(path, { force: true })));
}
interface ManagedPythonDaemonStopCandidate {
pid: number;
source: ManagedPythonDaemonStopAllSource;
host?: string;
port?: number;
version?: string;
command?: string;
statePaths: string[];
}
function candidateUrl(candidate: ManagedPythonDaemonStopCandidate): string | undefined {
if (!candidate.host || !candidate.port) {
return undefined;
}
return `http://${candidate.host}:${candidate.port}`;
}
function candidateEntry(candidate: ManagedPythonDaemonStopCandidate): ManagedPythonDaemonStopAllEntry {
return {
pid: candidate.pid,
source: candidate.source,
...(candidateUrl(candidate) ? { url: candidateUrl(candidate) } : {}),
...(candidate.version ? { version: candidate.version } : {}),
...(candidate.command ? { command: candidate.command } : {}),
statePaths: [...candidate.statePaths],
};
}
async function probeCandidateHealth(
candidate: ManagedPythonDaemonStopCandidate,
timeoutMs: number,
): Promise<'healthy' | 'unreachable' | undefined> {
const url = candidateUrl(candidate);
if (!url) {
return undefined;
}
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(`${url}/health`, { signal: controller.signal });
if (!response.ok) {
return 'unreachable';
}
const body = (await response.json()) as unknown;
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return 'unreachable';
}
return (body as Record<string, unknown>).status === 'healthy' ? 'healthy' : 'unreachable';
} catch {
return 'unreachable';
} finally {
clearTimeout(timeout);
}
}
async function readStateCandidates(runtimeRoot: string): Promise<ManagedPythonDaemonStopCandidate[]> {
let entries;
try {
entries = await readdir(runtimeRoot, { withFileTypes: true });
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === 'ENOENT') {
return [];
}
throw error;
}
const candidates: ManagedPythonDaemonStopCandidate[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const statePath = join(runtimeRoot, entry.name, 'daemon.json');
let state: ManagedPythonDaemonState | undefined;
try {
state = await readState(statePath);
} catch {
continue;
}
if (!state) {
continue;
}
candidates.push({
pid: state.pid,
source: 'state',
host: state.host,
port: state.port,
version: state.version,
statePaths: [statePath],
});
}
return candidates;
}
function tokenizeCommand(command: string): string[] {
const tokens: string[] = [];
for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
tokens.push(match[1] ?? match[2] ?? match[3] ?? '');
}
return tokens;
}
function executableName(token: string): string {
return token.split(/[\\/]/).at(-1) ?? token;
}
function isKtxDaemonExecutable(token: string): boolean {
return executableName(token) === 'ktx-daemon' || executableName(token) === 'ktx-daemon.exe';
}
function normalizedExecutableName(token: string): string {
return executableName(token).replace(/\.exe$/i, '').toLowerCase();
}
function hasUvRunPrefix(tokens: string[], daemonIndex: number): boolean {
return normalizedExecutableName(tokens[0] ?? '') === 'uv' && tokens.slice(1, daemonIndex).includes('run');
}
function isPythonExecutable(token: string): boolean {
const name = normalizedExecutableName(token);
return name === 'python' || name === 'python3';
}
function hasPythonModulePrefix(tokens: string[], moduleFlagIndex: number): boolean {
if (moduleFlagIndex === 1 && isPythonExecutable(tokens[0] ?? '')) {
return true;
}
return (
normalizedExecutableName(tokens[0] ?? '') === 'uv' &&
tokens.slice(1, moduleFlagIndex).includes('run') &&
tokens.some((token, index) => index < moduleFlagIndex && isPythonExecutable(token))
);
}
function isKtxDaemonServeHttp(tokens: string[]): boolean {
for (let index = 0; index < tokens.length; index += 1) {
if (
isKtxDaemonExecutable(tokens[index] ?? '') &&
tokens[index + 1] === 'serve-http' &&
(index === 0 || hasUvRunPrefix(tokens, index))
) {
return true;
}
if (
tokens[index] === '-m' &&
tokens[index + 1] === 'ktx_daemon' &&
tokens[index + 2] === 'serve-http' &&
hasPythonModulePrefix(tokens, index)
) {
return true;
}
}
return false;
}
function parseCommandOption(tokens: string[], option: string): string | undefined {
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token === option) {
return tokens[index + 1];
}
if (token?.startsWith(`${option}=`)) {
return token.slice(option.length + 1);
}
}
return undefined;
}
function processCandidate(processInfo: ManagedPythonDaemonProcessInfo): ManagedPythonDaemonStopCandidate | undefined {
const tokens = tokenizeCommand(processInfo.command);
if (!isKtxDaemonServeHttp(tokens)) {
return undefined;
}
const host = parseCommandOption(tokens, '--host') ?? '127.0.0.1';
const rawPort = parseCommandOption(tokens, '--port');
const parsedPort = rawPort ? Number.parseInt(rawPort, 10) : 8765;
const port = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort : 8765;
return {
pid: processInfo.pid,
source: 'process',
host,
port,
command: processInfo.command,
statePaths: [],
};
}
function mergeCandidates(candidates: ManagedPythonDaemonStopCandidate[]): ManagedPythonDaemonStopCandidate[] {
const byPid = new Map<number, ManagedPythonDaemonStopCandidate>();
for (const candidate of candidates) {
const existing = byPid.get(candidate.pid);
if (!existing) {
byPid.set(candidate.pid, { ...candidate, statePaths: [...candidate.statePaths] });
continue;
}
existing.statePaths.push(...candidate.statePaths);
if (existing.source === 'process' && candidate.source === 'state') {
byPid.set(candidate.pid, {
...candidate,
statePaths: [...new Set([...existing.statePaths, ...candidate.statePaths])],
});
} else {
existing.statePaths = [...new Set(existing.statePaths)];
}
}
return [...byPid.values()].sort((left, right) => left.pid - right.pid);
}
function parsePosixProcessList(output: string): ManagedPythonDaemonProcessInfo[] {
const processes: ManagedPythonDaemonProcessInfo[] = [];
for (const line of output.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+)\s+(.+)$/);
if (!match) {
continue;
}
processes.push({ pid: Number.parseInt(match[1], 10), command: match[2] });
}
return processes;
}
function parseWindowsProcessList(output: string): ManagedPythonDaemonProcessInfo[] {
if (!output.trim()) {
return [];
}
const parsed = JSON.parse(output) as unknown;
const records = Array.isArray(parsed) ? parsed : [parsed];
const processes: ManagedPythonDaemonProcessInfo[] = [];
for (const record of records) {
if (!record || typeof record !== 'object') {
continue;
}
const value = record as Record<string, unknown>;
const pid = value.ProcessId;
const command = value.CommandLine;
if (typeof pid === 'number' && typeof command === 'string' && command.length > 0) {
processes.push({ pid, command });
}
}
return processes;
}
async function defaultListProcesses(platform: NodeJS.Platform = process.platform): Promise<ManagedPythonDaemonProcessInfo[]> {
if (platform === 'win32') {
const command = [
'Get-CimInstance Win32_Process',
'| Where-Object { $_.CommandLine -ne $null }',
'| Select-Object ProcessId,CommandLine',
'| ConvertTo-Json -Compress',
].join(' ');
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command], {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
return parseWindowsProcessList(stdout);
}
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,command='], {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
return parsePosixProcessList(stdout);
}
async function waitUntilStopped(input: {
pid: number;
processAlive: (pid: number) => boolean;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
const deadline = Date.now() + input.timeoutMs;
do {
if (!input.processAlive(input.pid)) {
return true;
}
if (Date.now() >= deadline) {
break;
}
await delay(input.pollIntervalMs);
} while (Date.now() <= deadline);
return !input.processAlive(input.pid);
}
async function discoverStopAllCandidates(
options: ManagedPythonDaemonStopAllOptions,
): Promise<{
runtimeRoot: string;
candidates: ManagedPythonDaemonStopCandidate[];
scanErrors: string[];
}> {
const runtimeRoot = runtimeRootForStopAll(options);
const stateCandidates = await readStateCandidates(runtimeRoot);
const scanErrors: string[] = [];
let processCandidates: ManagedPythonDaemonStopCandidate[] = [];
try {
const processes = await (options.listProcesses ?? defaultListProcesses)();
processCandidates = processes.flatMap((processInfo) => {
const candidate = processCandidate(processInfo);
return candidate ? [candidate] : [];
});
} catch (error) {
scanErrors.push(error instanceof Error ? error.message : String(error));
}
return {
runtimeRoot,
candidates: mergeCandidates([...stateCandidates, ...processCandidates]),
scanErrors,
};
}
export async function startManagedPythonDaemon(
options: ManagedPythonDaemonStartOptions,
): Promise<ManagedPythonDaemonStartResult> {
@ -404,3 +765,63 @@ export async function stopManagedPythonDaemon(
});
return { status: 'stopped', layout, state };
}
export async function stopAllManagedPythonDaemons(
options: ManagedPythonDaemonStopAllOptions,
): Promise<ManagedPythonDaemonStopAllResult> {
const processAlive = options.processAlive ?? defaultProcessAlive;
const killProcess = options.killProcess ?? defaultKillProcess;
const stopGraceMs = options.stopGraceMs ?? 500;
const pollIntervalMs = options.pollIntervalMs ?? 50;
const healthProbeMs = options.healthProbeMs ?? 100;
const discovery = await discoverStopAllCandidates(options);
const stopped: ManagedPythonDaemonStopAllEntry[] = [];
const stale: ManagedPythonDaemonStopAllEntry[] = [];
const failed: ManagedPythonDaemonStopAllFailure[] = [];
for (const candidate of discovery.candidates) {
const health = await probeCandidateHealth(candidate, healthProbeMs);
const entry = { ...candidateEntry(candidate), ...(health ? { health } : {}) };
if (!processAlive(candidate.pid)) {
await removeStatePaths(candidate.statePaths);
stale.push(entry);
continue;
}
try {
killProcess(candidate.pid, 'SIGTERM');
if (
!(await waitUntilStopped({
pid: candidate.pid,
processAlive,
timeoutMs: stopGraceMs,
pollIntervalMs,
}))
) {
killProcess(candidate.pid, 'SIGKILL');
if (
!(await waitUntilStopped({
pid: candidate.pid,
processAlive,
timeoutMs: stopGraceMs,
pollIntervalMs,
}))
) {
failed.push({ ...entry, detail: 'Process still running after SIGKILL' });
continue;
}
}
await removeStatePaths(candidate.statePaths);
stopped.push(entry);
} catch (error) {
failed.push({ ...entry, detail: error instanceof Error ? error.message : String(error) });
}
}
return {
runtimeRoot: discovery.runtimeRoot,
stopped,
stale,
failed,
scanErrors: discovery.scanErrors,
};
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type {
ManagedPythonDaemonStopAllResult,
ManagedPythonDaemonStartResult,
ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
@ -199,13 +200,63 @@ describe('runKtxRuntime', () => {
})),
};
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0' }, io.io, deps)).resolves.toBe(0);
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: false }, io.io, deps)).resolves.toBe(0);
expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
expect(io.stdout()).toContain('Stopped KTX Python daemon');
expect(io.stdout()).toContain('pid: 4242');
});
it('stops all discovered Python daemons and reports the summary', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
stopAllDaemons: vi.fn(async (): Promise<ManagedPythonDaemonStopAllResult> => ({
runtimeRoot: '/runtime',
stopped: [
{ pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/runtime/0.2.0/daemon.json'] },
{ pid: 5252, source: 'process', url: 'http://127.0.0.1:8765', statePaths: [] },
],
stale: [],
failed: [],
scanErrors: [],
})),
};
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(0);
expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
expect(io.stdout()).toContain('Stopped 2 KTX Python daemons');
expect(io.stdout()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
expect(io.stdout()).toContain('pid: 5252 source: process url: http://127.0.0.1:8765');
});
it('returns failure when stop all cannot stop every daemon', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
stopAllDaemons: vi.fn(async (): Promise<ManagedPythonDaemonStopAllResult> => ({
runtimeRoot: '/runtime',
stopped: [],
stale: [],
failed: [
{
pid: 4242,
source: 'state',
url: 'http://127.0.0.1:61234',
statePaths: ['/runtime/0.2.0/daemon.json'],
detail: 'Process still running after SIGKILL',
},
],
scanErrors: ['ps failed'],
})),
};
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(1);
expect(io.stderr()).toContain('Stopped 0 KTX Python daemons; failed 1');
expect(io.stderr()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
expect(io.stderr()).toContain('process scan: ps failed');
});
it('prints runtime status as JSON', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {

View file

@ -1,7 +1,9 @@
import type { KtxCliIo } from './cli-runtime.js';
import {
stopAllManagedPythonDaemons,
startManagedPythonDaemon,
stopManagedPythonDaemon,
type ManagedPythonDaemonStopAllResult,
type ManagedPythonDaemonStartResult,
type ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
@ -22,7 +24,7 @@ import {
export type KtxRuntimeArgs =
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'stop'; cliVersion: string }
| { command: 'stop'; cliVersion: string; all: boolean }
| { command: 'status'; cliVersion: string; json: boolean }
| { command: 'doctor'; cliVersion: string; json: boolean }
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
@ -35,6 +37,7 @@ export interface KtxRuntimeDeps {
force?: boolean;
}) => Promise<ManagedPythonDaemonStartResult>;
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
stopAllDaemons?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopAllResult>;
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
pruneRuntime?: (options: {
@ -81,6 +84,58 @@ function writeDaemonStop(io: KtxCliIo, result: ManagedPythonDaemonStopResult): v
io.stdout.write(`state: ${result.layout.daemonStatePath}\n`);
}
function writeStopAllEntry(io: KtxCliIo, entry: { pid: number; source: string; url?: string; health?: string; detail?: string }): void {
io.stdout.write(
`pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${
entry.health ? ` health: ${entry.health}` : ''
}${
entry.detail ? ` detail: ${entry.detail}` : ''
}\n`,
);
}
function writeDaemonStopAll(io: KtxCliIo, result: ManagedPythonDaemonStopAllResult): number {
const failed = result.failed.length + result.scanErrors.length;
if (
result.stopped.length === 0 &&
result.stale.length === 0 &&
result.failed.length === 0 &&
result.scanErrors.length === 0
) {
io.stdout.write('No KTX Python daemons found\n');
return 0;
}
if (failed === 0) {
io.stdout.write(`Stopped ${result.stopped.length} KTX Python daemons\n`);
if (result.stale.length > 0) {
io.stdout.write(`Cleaned ${result.stale.length} stale daemon states\n`);
}
for (const entry of result.stopped) {
writeStopAllEntry(io, entry);
}
for (const entry of result.stale) {
writeStopAllEntry(io, entry);
}
return 0;
}
io.stderr.write(
`Stopped ${result.stopped.length} KTX Python daemons; failed ${result.failed.length}${
result.stale.length > 0 ? `; cleaned stale ${result.stale.length}` : ''
}\n`,
);
for (const entry of result.failed) {
io.stderr.write(
`pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${
entry.health ? ` health: ${entry.health}` : ''
} detail: ${entry.detail}\n`,
);
}
for (const error of result.scanErrors) {
io.stderr.write(`process scan: ${error}\n`);
}
return 1;
}
function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
io.stdout.write('KTX Python runtime\n');
io.stdout.write(`status: ${status.kind}\n`);
@ -142,10 +197,16 @@ export async function runKtxRuntime(
return 0;
}
if (args.command === 'stop') {
const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
const result = await stopDaemon({ cliVersion: args.cliVersion });
writeDaemonStop(io, result);
return 0;
if (args.all) {
const stopAllDaemons = deps.stopAllDaemons ?? stopAllManagedPythonDaemons;
const result = await stopAllDaemons({ cliVersion: args.cliVersion });
return writeDaemonStopAll(io, result);
} else {
const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
const result = await stopDaemon({ cliVersion: args.cliVersion });
writeDaemonStop(io, result);
return 0;
}
}
if (args.command === 'status') {
const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
import { runDemoTour } from './setup-demo-tour.js';
import { readKtxSetupStatus, runKtxSetup } from './setup.js';
@ -311,6 +312,62 @@ describe('setup status', () => {
});
});
it('reports Vertex LLM and context ready after a successful Metabase ingest report', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'project: revenue',
'setup:',
' database_connection_ids:',
' - warehouse',
' completed_steps:',
' - project',
' - databases',
' - sources',
'connections:',
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' metabase:',
' driver: metabase',
' url: env:METABASE_URL',
' api_key_ref: env:METABASE_API_KEY',
' warehouse_connection_id: warehouse',
'llm:',
' provider:',
' backend: vertex',
' vertex:',
' project: kaelio-dev',
' location: us-east5',
' models:',
' default: claude-sonnet-4-6',
'ingest:',
' embeddings:',
' backend: deterministic',
' model: deterministic',
' dimensions: 8',
'',
].join('\n'),
'utf-8',
);
await persistLocalBundleReport(
tempDir,
localFakeBundleReport('metabase-job-1', {
connectionId: 'warehouse',
sourceKey: 'metabase',
}),
);
const status = await readKtxSetupStatus(tempDir);
const io = makeIo();
await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, io.io)).resolves.toBe(0);
expect(status.llm).toMatchObject({ backend: 'vertex', ready: true, model: 'claude-sonnet-4-6' });
expect(status.context).toMatchObject({ ready: true, status: 'completed' });
expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)');
expect(io.stdout()).toContain('KTX context built: yes');
});
it('prints plain and JSON setup status', async () => {
const plainIo = makeIo();
const jsonIo = makeIo();

View file

@ -1,7 +1,8 @@
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { cancel, isCancel, select } from '@clack/prompts';
import { loadKtxProject } from '@ktx/context/project';
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
import { ktxLocalStateDbPath, loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
@ -248,6 +249,31 @@ function sourceConnections(config: Awaited<ReturnType<typeof loadKtxProject>>['c
.sort((left, right) => left.connectionId.localeCompare(right.connectionId));
}
type LocalIngestStatusReport = NonNullable<Awaited<ReturnType<typeof getLatestLocalIngestStatus>>>;
function reportHasSavedContext(report: LocalIngestStatusReport): boolean {
if (report.body.failedWorkUnits.length > 0) {
return false;
}
const counts = savedMemoryCountsForReport(report);
return counts.wikiCount > 0 || counts.slCount > 0;
}
async function readIngestContextStatus(project: KtxLocalProject): Promise<KtxSetupContextStatusSummary | null> {
if (!existsSync(ktxLocalStateDbPath(project))) {
return null;
}
const report = await getLatestLocalIngestStatus(project);
if (!report || !reportHasSavedContext(report)) {
return null;
}
return {
ready: true,
status: 'completed',
runId: report.runId,
};
}
export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupStatus> {
const resolvedProjectDir = resolve(projectDir);
if (!existsSync(join(resolvedProjectDir, 'ktx.yaml'))) {
@ -279,6 +305,10 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
const completedSteps = project.config.setup?.completed_steps ?? [];
const contextState = await readKtxSetupContextState(resolvedProjectDir);
const setupContextStatus = setupContextStatusFromState(contextState, {
completedStep: completedSteps.includes('context'),
});
const ingestContextStatus = setupContextStatus.ready ? null : await readIngestContextStatus(project);
const databaseIds = project.config.setup?.database_connection_ids ?? Object.keys(project.config.connections);
const databasesComplete = completedSteps.includes('databases');
const manifest = await readKtxAgentInstallManifest(resolvedProjectDir);
@ -301,7 +331,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
...source,
ready: completedSteps.includes('sources'),
})),
context: setupContextStatusFromState(contextState, { completedStep: completedSteps.includes('context') }),
context: ingestContextStatus ?? setupContextStatus,
agents,
};
}

View file

@ -368,9 +368,9 @@ describe('standalone built ktx CLI smoke', () => {
const knowledgeSearch = structuredContent<{
results: Array<{ key: string; summary: string; score: number }>;
totalFound: number;
}>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract', limit: 5 } }));
}>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract-first definition', limit: 10 } }));
expect(knowledgeSearch.totalFound).toBeGreaterThan(0);
expect(knowledgeSearch.results.map((result) => result.key)).toContain('arr-contract-first');
expect(knowledgeSearch.results.map((result) => result.key)).toContain('orbit-arr-contract-first-definition');
const knowledgeRead = structuredContent<{
key: string;
@ -378,26 +378,26 @@ describe('standalone built ktx CLI smoke', () => {
content: string;
tags: string[];
slRefs: string[];
}>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'arr-contract-first' } }));
expect(knowledgeRead.key).toBe('arr-contract-first');
}>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'orbit-arr-contract-first-definition' } }));
expect(knowledgeRead.key).toBe('orbit-arr-contract-first-definition');
expect(knowledgeRead.summary).toContain('ARR');
expect(knowledgeRead.content).toContain('contract');
expect(knowledgeRead.slRefs).toContain('orbit_demo.contracts');
expect(knowledgeRead.slRefs).toContain('mart_arr_daily');
const slRead = structuredContent<{ sourceName: string; yaml: string }>(
await client.callTool({
name: 'sl_read_source',
arguments: { connectionId: 'orbit_demo', sourceName: 'accounts' },
arguments: { connectionId: 'dbt-main', sourceName: 'mart_arr_daily' },
}),
);
expect(slRead.sourceName).toBe('accounts');
expect(slRead.yaml).toContain('name: accounts');
expect(slRead.sourceName).toBe('mart_arr_daily');
expect(slRead.yaml).toContain('name: mart_arr_daily');
expect(slRead.yaml).toContain('measures:');
const slValidate = structuredContent<{ success: boolean; errors: string[]; warnings: string[] }>(
await client.callTool({
name: 'sl_validate',
arguments: { connectionId: 'orbit_demo', names: ['accounts', 'contracts'] },
arguments: { connectionId: 'dbt-main', names: ['mart_arr_daily', 'stg_contracts'] },
}),
);
expect(slValidate.success).toBe(true);
@ -716,7 +716,7 @@ describe('standalone built ktx CLI smoke', () => {
'--project-dir',
projectDir,
'--token-env',
'NOTION_AUTH_TOKEN',
'NOTION_TOKEN',
'--crawl-mode',
'all_accessible',
'--max-pages',
@ -729,7 +729,7 @@ describe('standalone built ktx CLI smoke', () => {
const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(yaml).toContain('driver: notion');
expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN');
expect(yaml).toContain('auth_token_ref: env:NOTION_TOKEN');
expect(yaml).toContain('crawl_mode: all_accessible');
expect(yaml).toContain('max_pages_per_run: 5');
expect(yaml).not.toContain('ntn_');
@ -737,7 +737,7 @@ describe('standalone built ktx CLI smoke', () => {
const parsed = parseKtxProjectConfig(yaml);
expect(parsed.connections['notion-main']).toMatchObject({
driver: 'notion',
auth_token_ref: 'env:NOTION_AUTH_TOKEN',
auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'all_accessible',
});
});

View file

@ -23,14 +23,14 @@ describe('standalone Notion connection config', () => {
it('parses selected-root Notion config with safe defaults', () => {
const parsed = parseNotionConnectionConfig({
driver: 'notion',
auth_token_ref: 'env:NOTION_AUTH_TOKEN',
auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'selected_roots',
root_page_ids: ['page-1'],
});
expect(parsed).toEqual({
driver: 'notion',
auth_token_ref: 'env:NOTION_AUTH_TOKEN',
auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'selected_roots',
root_page_ids: ['page-1'],
root_database_ids: [],
@ -70,7 +70,7 @@ describe('standalone Notion connection config', () => {
expect(() =>
parseNotionConnectionConfig({
driver: 'notion',
auth_token_ref: 'env:NOTION_AUTH_TOKEN',
auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'selected_roots',
}),
).toThrow('selected_roots requires at least one root page, database, or data source id');
@ -81,8 +81,8 @@ describe('standalone Notion connection config', () => {
await writeFile(tokenPath, 'ntn_file_token\n', 'utf-8');
await expect(
resolveNotionAuthToken('env:NOTION_AUTH_TOKEN', {
env: { NOTION_AUTH_TOKEN: 'ntn_env_token' },
resolveNotionAuthToken('env:NOTION_TOKEN', {
env: { NOTION_TOKEN: 'ntn_env_token' },
}),
).resolves.toBe('ntn_env_token');
await expect(resolveNotionAuthToken(`file:${tokenPath}`)).resolves.toBe('ntn_file_token');
@ -95,14 +95,14 @@ describe('standalone Notion connection config', () => {
const pullConfig = await notionConnectionToPullConfig(
parseNotionConnectionConfig({
driver: 'notion',
auth_token_ref: 'env:NOTION_AUTH_TOKEN',
auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'all_accessible',
max_pages_per_run: 12,
max_knowledge_creates_per_run: 2,
max_knowledge_updates_per_run: 7,
last_successful_cursor: '{"phase":"all_accessible_pages","cursor":"cursor-1"}',
}),
{ env: { NOTION_AUTH_TOKEN: 'ntn_env_token' } },
{ env: { NOTION_TOKEN: 'ntn_env_token' } },
);
expect(pullConfig).toEqual({

View file

@ -569,8 +569,8 @@ describe('local ingest', () => {
});
it('passes resolved standalone Notion config into fetch adapters', async () => {
const priorToken = process.env.NOTION_AUTH_TOKEN;
process.env.NOTION_AUTH_TOKEN = 'ntn_local_test_token';
const priorToken = process.env.NOTION_TOKEN;
process.env.NOTION_TOKEN = 'ntn_local_test_token';
try {
await writeFile(
join(project.projectDir, 'ktx.yaml'),
@ -579,7 +579,7 @@ describe('local ingest', () => {
'connections:',
' notion-main:',
' driver: notion',
' auth_token_ref: env:NOTION_AUTH_TOKEN',
' auth_token_ref: env:NOTION_TOKEN',
' crawl_mode: selected_roots',
' root_page_ids:',
' - page-1',
@ -666,9 +666,9 @@ describe('local ingest', () => {
});
} finally {
if (priorToken === undefined) {
delete process.env.NOTION_AUTH_TOKEN;
delete process.env.NOTION_TOKEN;
} else {
process.env.NOTION_AUTH_TOKEN = priorToken;
process.env.NOTION_TOKEN = priorToken;
}
}
});

View file

@ -37,6 +37,7 @@ interface BuiltMocks {
agentRunner: any;
slValidator: any;
toolsetFactory: any;
logger: any;
}
const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
@ -131,6 +132,7 @@ const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
getAllTools: vi.fn().mockReturnValue([]),
}),
},
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
};
return { ...defaults, ...overrides };
@ -179,6 +181,7 @@ const buildService = (mocks: BuiltMocks): MemoryAgentService =>
telemetry: {
trackMemoryIngestion: mocks.eventTracker.trackEvent,
},
logger: mocks.logger,
});
const baseInput = {
@ -238,6 +241,27 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => {
expect(result.commitHash).toBe('cafebabe');
});
it('logs prompt debug output when KTX_MEMORY_AGENT_DEBUG_PROMPTS is enabled', async () => {
const previousDebugPrompts = process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
const mocks = buildMocks();
const svc = buildService(mocks);
try {
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = '1';
await svc.ingest(baseInput);
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] system='));
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] user='));
} finally {
if (previousDebugPrompts === undefined) {
delete process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
} else {
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = previousDebugPrompts;
}
}
});
it('empty path: squash returns no touched paths → no enqueue, cleanup(empty), commitHash=null', async () => {
const mocks = buildMocks();
mocks.gitService.squashMergeIntoMain.mockResolvedValue({

View file

@ -192,7 +192,7 @@ export class MemoryAgentService {
`[memory-agent] chat=${chatId} running (sourceType=${sourceType}, hasSL=${hasSL}, budget=${stepBudget}, model=${modelName})${signalsSuffix}${dialectSuffix}`,
);
if (process.env.MEMORY_AGENT_DEBUG_PROMPTS === '1') {
if (process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS === '1') {
this.logger.debug(`[memory-agent prompt-debug] system=${systemPrompt}`);
this.logger.debug(`[memory-agent prompt-debug] user=${prompt}`);
}

View file

@ -27,6 +27,7 @@
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/devtools": "0.0.17",
"@ai-sdk/google-vertex": "^4.0.112",
"ai": "^6.0.168",
"openai": "^6.25.0"

View file

@ -1,3 +1,4 @@
import { wrapLanguageModel as defaultWrapLanguageModel } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { runKtxLlmHealthCheck } from './model-health.js';
@ -7,6 +8,7 @@ describe('KTX LLM health check', () => {
it('runs a minimal non-streaming model call through the configured provider', async () => {
const generateText = vi.fn(async () => ({ text: 'ok' }));
const createAnthropic = vi.fn(() => vi.fn(() => anthropicModel));
const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
await expect(
runKtxLlmHealthCheck(
@ -15,7 +17,7 @@ describe('KTX LLM health check', () => {
anthropic: { apiKey: 'sk-ant-test' },
modelSlots: { default: 'claude-sonnet-4-6' },
},
{ deps: { createAnthropic, generateText } },
{ deps: { createAnthropic, generateText, devtoolsEnabled: true, wrapLanguageModel } },
),
).resolves.toEqual({ ok: true });
@ -32,6 +34,7 @@ describe('KTX LLM health check', () => {
maxOutputTokens: 8,
}),
);
expect(wrapLanguageModel).not.toHaveBeenCalled();
});
it('returns a failed result without exposing secret values', async () => {

View file

@ -41,7 +41,7 @@ export async function runKtxLlmHealthCheck(
): Promise<KtxLlmHealthCheckResult> {
try {
const { generateText: runGenerateTextOverride, ...providerDeps } = options.deps ?? {};
const provider = createKtxLlmProvider(config, providerDeps);
const provider = createKtxLlmProvider(config, { ...providerDeps, devtoolsEnabled: false });
const runGenerateText = runGenerateTextOverride ?? generateText;
await withTimeout(
runGenerateText({

View file

@ -1,10 +1,138 @@
import type { LanguageModel } from 'ai';
import { devToolsMiddleware as defaultDevToolsMiddleware } from '@ai-sdk/devtools';
import { wrapLanguageModel as defaultWrapLanguageModel, type LanguageModel } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { createKtxLlmProvider } from './model-provider.js';
import { createKtxLlmProvider, type KtxLlmProviderFactoryDeps } from './model-provider.js';
const languageModel = (modelId: string, provider = 'test'): LanguageModel => ({ modelId, provider }) as LanguageModel;
const devtoolsMiddleware = (): ReturnType<typeof defaultDevToolsMiddleware> => ({ specificationVersion: 'v3' });
const wrapWith = (model: LanguageModel) =>
vi.fn((_options: Parameters<typeof defaultWrapLanguageModel>[0]) => model as ReturnType<typeof defaultWrapLanguageModel>);
describe('createKtxLlmProvider', () => {
it('wraps language models with DevTools middleware when explicitly enabled', () => {
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
const wrappedModel = languageModel('claude-sonnet-4-6', 'anthropic-devtools');
const middleware = devtoolsMiddleware();
const wrapLanguageModel = wrapWith(wrappedModel);
const devToolsMiddleware = vi.fn(devtoolsMiddleware);
const provider = createKtxLlmProvider(
{
backend: 'anthropic',
anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
modelSlots: { default: 'claude-sonnet-4-6' },
promptCaching: { enabled: false },
},
{
createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
devtoolsEnabled: true,
wrapLanguageModel,
devToolsMiddleware,
} satisfies KtxLlmProviderFactoryDeps,
);
expect(provider.getModel('default')).toBe(wrappedModel);
expect(devToolsMiddleware).toHaveBeenCalledTimes(1);
expect(wrapLanguageModel).toHaveBeenCalledWith({
model: anthropicModel,
middleware,
modelId: 'claude-sonnet-4-6',
providerId: 'anthropic',
});
});
it('does not wrap language models by default', () => {
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware);
const provider = createKtxLlmProvider(
{
backend: 'anthropic',
anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
modelSlots: { default: 'claude-sonnet-4-6' },
promptCaching: { enabled: false },
},
{
createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
wrapLanguageModel,
devToolsMiddleware,
} satisfies KtxLlmProviderFactoryDeps,
);
expect(provider.getModel('default')).toBe(anthropicModel);
expect(wrapLanguageModel).not.toHaveBeenCalled();
expect(devToolsMiddleware).not.toHaveBeenCalled();
});
it('wraps language models when KTX_AI_DEVTOOLS_ENABLED is true', () => {
const originalEnv = process.env.KTX_AI_DEVTOOLS_ENABLED;
process.env.KTX_AI_DEVTOOLS_ENABLED = 'true';
try {
const gatewayModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway');
const wrappedModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway-devtools');
const wrapLanguageModel = wrapWith(wrappedModel);
const provider = createKtxLlmProvider(
{
backend: 'gateway',
gateway: { baseURL: 'https://gateway.test/v1' },
modelSlots: { default: 'anthropic/claude-sonnet-4-6' },
promptCaching: { enabled: false },
},
{
createGateway: vi.fn(() => vi.fn(() => gatewayModel)),
wrapLanguageModel,
devToolsMiddleware: vi.fn(devtoolsMiddleware),
} satisfies KtxLlmProviderFactoryDeps,
);
expect(provider.getModel('default')).toBe(wrappedModel);
expect(wrapLanguageModel).toHaveBeenCalledTimes(1);
} finally {
if (originalEnv === undefined) {
delete process.env.KTX_AI_DEVTOOLS_ENABLED;
} else {
process.env.KTX_AI_DEVTOOLS_ENABLED = originalEnv;
}
}
});
it('does not wrap language models in production even when enabled', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
try {
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware);
const provider = createKtxLlmProvider(
{
backend: 'anthropic',
anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
modelSlots: { default: 'claude-sonnet-4-6' },
promptCaching: { enabled: false },
},
{
createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
devtoolsEnabled: true,
wrapLanguageModel,
devToolsMiddleware,
} satisfies KtxLlmProviderFactoryDeps,
);
expect(provider.getModel('default')).toBe(anthropicModel);
expect(wrapLanguageModel).not.toHaveBeenCalled();
expect(devToolsMiddleware).not.toHaveBeenCalled();
} finally {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
}
});
it('uses direct Anthropic with both beta headers', () => {
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
const anthropic = vi.fn(() => anthropicModel);

View file

@ -1,6 +1,7 @@
import { createAnthropic } from '@ai-sdk/anthropic';
import { devToolsMiddleware } from '@ai-sdk/devtools';
import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
import { createGateway, generateText, type LanguageModel } from 'ai';
import { createGateway, generateText, wrapLanguageModel, type LanguageModel } from 'ai';
import { createKtxToolCallRepairHandler } from './repair.js';
import type {
KtxLlmConfig,
@ -21,6 +22,9 @@ export interface KtxLlmProviderFactoryDeps {
createVertexAnthropic?: VertexAnthropicFactory;
createGateway?: GatewayFactory;
generateText?: typeof generateText;
devtoolsEnabled?: boolean;
wrapLanguageModel?: typeof wrapLanguageModel;
devToolsMiddleware?: typeof devToolsMiddleware;
}
const DEFAULT_PROMPT_CACHING: KtxPromptCachingConfig = {
@ -40,10 +44,27 @@ function resolvePromptCaching(config: KtxLlmConfig): KtxPromptCachingConfig {
return { ...DEFAULT_PROMPT_CACHING, ...config.promptCaching };
}
function resolveDevtoolsEnabled(override: boolean | undefined): boolean {
if (process.env.NODE_ENV === 'production') {
return false;
}
if (override !== undefined) {
return override;
}
const value = process.env.KTX_AI_DEVTOOLS_ENABLED?.trim().toLowerCase();
return value === 'true' || value === '1' || value === 'yes';
}
export function modelIdFromLanguageModel(model: LanguageModel | string): string {
return typeof model === 'string' ? model : ((model as { modelId?: string }).modelId ?? '');
}
function providerIdFromLanguageModel(model: Exclude<LanguageModel, string>): string | undefined {
return typeof (model as { provider?: unknown }).provider === 'string'
? (model as { provider: string }).provider
: undefined;
}
export function isAnthropicProtocolModel(model: LanguageModel | string): boolean {
const modelId = modelIdFromLanguageModel(model);
return modelId.startsWith('claude-') || modelId.startsWith('anthropic/') || modelId.includes('/claude-');
@ -53,6 +74,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
private readonly promptCaching: KtxPromptCachingConfig;
private readonly getModelByResolvedName: (modelId: string) => LanguageModel;
private readonly runGenerateText: typeof generateText;
private readonly devtoolsEnabled: boolean;
private readonly runWrapLanguageModel: typeof wrapLanguageModel;
private readonly createDevToolsMiddleware: typeof devToolsMiddleware;
constructor(
private readonly config: KtxLlmConfig,
@ -60,6 +84,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
) {
this.promptCaching = resolvePromptCaching(config);
this.runGenerateText = deps.generateText ?? generateText;
this.devtoolsEnabled = resolveDevtoolsEnabled(deps.devtoolsEnabled);
this.runWrapLanguageModel = deps.wrapLanguageModel ?? wrapLanguageModel;
this.createDevToolsMiddleware = deps.devToolsMiddleware ?? devToolsMiddleware;
this.getModelByResolvedName = this.createModelFactory(config, deps);
}
@ -68,7 +95,7 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
}
getModelByName(modelId: string): LanguageModel {
return this.getModelByResolvedName(modelId);
return this.withDevtools(this.getModelByResolvedName(modelId));
}
cacheMarker(ttl: KtxPromptCacheTtl, model?: LanguageModel | string) {
@ -113,6 +140,18 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
return this.config.modelSlots[role] ?? this.config.modelSlots.default;
}
private withDevtools(model: LanguageModel): LanguageModel {
if (!this.devtoolsEnabled || typeof model === 'string') {
return model;
}
return this.runWrapLanguageModel({
model: model as Parameters<typeof wrapLanguageModel>[0]['model'],
middleware: this.createDevToolsMiddleware(),
modelId: modelIdFromLanguageModel(model),
providerId: providerIdFromLanguageModel(model),
});
}
private createModelFactory(config: KtxLlmConfig, deps: KtxLlmProviderFactoryDeps): (modelId: string) => LanguageModel {
if (config.backend === 'anthropic') {
const anthropic = (deps.createAnthropic ?? createAnthropic)({

14
pnpm-lock.yaml generated
View file

@ -355,6 +355,9 @@ importers:
'@ai-sdk/anthropic':
specifier: 3.0.71
version: 3.0.71(zod@4.4.3)
'@ai-sdk/devtools':
specifier: 0.0.17
version: 0.0.17
'@ai-sdk/google-vertex':
specifier: ^4.0.112
version: 4.0.118(zod@4.4.3)
@ -389,6 +392,11 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/devtools@0.0.17':
resolution: {integrity: sha512-CJgo+3DMHOJbxxq1qTgnW4vpFXgBW1pHePMimBW4Go5FPU7iLqppoGX/UC798IXqlD3hncQRPfyBLZjbsJC91w==}
engines: {node: '>=18'}
hasBin: true
'@ai-sdk/gateway@3.0.104':
resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
engines: {node: '>=18'}
@ -4576,6 +4584,12 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.26(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/devtools@0.0.17':
dependencies:
'@ai-sdk/provider': 3.0.10
'@hono/node-server': 1.19.14(hono@4.12.15)
hono: 4.12.15
'@ai-sdk/gateway@3.0.104(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8

View file

@ -135,72 +135,86 @@ describe('standalone example docs', () => {
assert.doesNotMatch(readme, /--historic-sql-min-calls/);
});
it('lists every published TypeScript package in the package root README', async () => {
const rootReadme = await readText('README.md');
it('lists every workspace package in the contributor docs', async () => {
const contributing = await readText('docs-site/content/docs/community/contributing.mdx');
assert.match(rootReadme, /`packages\/context`/);
assert.match(rootReadme, /`packages\/cli`/);
assert.match(rootReadme, /`packages\/connector-bigquery`/);
assert.match(rootReadme, /`packages\/connector-clickhouse`/);
assert.match(rootReadme, /`packages\/connector-mysql`/);
assert.match(rootReadme, /`packages\/connector-postgres`/);
assert.match(rootReadme, /`packages\/connector-snowflake`/);
assert.match(rootReadme, /`packages\/connector-sqlite`/);
assert.match(rootReadme, /`packages\/connector-sqlserver`/);
assert.match(rootReadme, /`python\/ktx-sl`/);
assert.match(rootReadme, /`python\/ktx-daemon`/);
assert.match(contributing, /cli\/\s+# CLI entry point/);
assert.match(contributing, /context\/\s+# Core context engine/);
assert.match(contributing, /llm\/\s+# LLM client abstraction/);
assert.match(contributing, /connector-bigquery\/\s+# BigQuery connector/);
assert.match(contributing, /connector-clickhouse\/\s+# ClickHouse connector/);
assert.match(contributing, /connector-mysql\/\s+# MySQL connector/);
assert.match(contributing, /connector-postgres\/\s+# PostgreSQL connector/);
assert.match(contributing, /connector-snowflake\/\s+# Snowflake connector/);
assert.match(contributing, /connector-sqlite\/\s+# SQLite connector/);
assert.match(contributing, /connector-sqlserver\/\s+# SQL Server connector/);
assert.match(contributing, /ktx-sl\/\s+# Semantic layer/);
assert.match(contributing, /ktx-daemon\/\s+# Daemon/);
});
it('documents every standalone MCP tool that the CLI server exposes', async () => {
const rootReadme = await readText('README.md');
const servingAgents = await readText('docs-site/content/docs/guides/serving-agents.mdx');
assert.match(rootReadme, /`connection_list`/);
assert.match(rootReadme, /`knowledge_search`/);
assert.match(rootReadme, /`knowledge_read`/);
assert.match(rootReadme, /`knowledge_write`/);
assert.match(rootReadme, /`sl_list_sources`/);
assert.match(rootReadme, /`sl_read_source`/);
assert.match(rootReadme, /`sl_write_source`/);
assert.match(rootReadme, /`sl_validate`/);
assert.match(rootReadme, /`sl_query`/);
assert.match(rootReadme, /`ingest_trigger`/);
assert.match(rootReadme, /`ingest_status`/);
assert.match(rootReadme, /`ingest_report`/);
assert.match(rootReadme, /`ingest_replay`/);
for (const tool of [
'connection_list',
'connection_test',
'knowledge_search',
'knowledge_read',
'knowledge_write',
'sl_list_sources',
'sl_read_source',
'sl_write_source',
'sl_validate',
'sl_query',
'scan_trigger',
'scan_status',
'scan_report',
'scan_list_artifacts',
'scan_read_artifact',
'ingest_trigger',
'ingest_status',
'ingest_report',
'ingest_replay',
'memory_capture',
'memory_capture_status',
]) {
assert.match(servingAgents, new RegExp(`\`${tool}\``));
}
});
it('walks through ktx connection list and ktx connection test in the README quickstart', async () => {
const rootReadme = await readText('README.md');
it('walks through connection testing in the quickstart and CLI reference', async () => {
const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx');
const connectionReference = await readText('docs-site/content/docs/cli-reference/ktx-connection.mdx');
assert.match(rootReadme, /connection list --project-dir/);
assert.match(rootReadme, /connection test warehouse --project-dir/);
assert.match(rootReadme, /Driver: sqlite/);
assert.match(rootReadme, /Tables: 1/);
assert.match(connectionReference, /ktx connection list/);
assert.match(connectionReference, /ktx connection test my-warehouse/);
assert.match(quickstart, /Connection test passed/);
assert.match(quickstart, /Driver: PostgreSQL .* Tables: 42/);
});
it('documents public npm and managed runtime usage in the README', async () => {
it('documents public npm and managed runtime usage', async () => {
const rootReadme = await readText('README.md');
const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx');
const packageArtifacts = await readText('examples/package-artifacts/README.md');
assert.match(rootReadme, publicPackagePattern('npx {package} setup demo --no-input'));
assert.match(rootReadme, publicPackagePattern('npx {package} sl query'));
assert.match(rootReadme, publicPackagePattern('npm install {package}'));
assert.match(rootReadme, publicPackagePattern('npm install -g {package}'));
assert.match(rootReadme, /ktx runtime install/);
assert.match(rootReadme, /ktx runtime status/);
assert.match(rootReadme, /ktx runtime doctor/);
assert.match(rootReadme, /ktx runtime start/);
assert.match(rootReadme, /ktx runtime stop/);
assert.match(rootReadme, /ktx runtime prune --dry-run/);
assert.match(rootReadme, /ktx runtime prune --yes/);
assert.match(rootReadme, /KTX requires `uv` on `PATH`/);
assert.match(rootReadme, /KTX doesn't download `uv` automatically/);
assert.match(quickstart, publicPackagePattern('npm install -g {package}'));
assert.match(quickstart, /ktx runtime install --feature local-embeddings --yes/);
assert.match(quickstart, /ktx runtime start --feature local-embeddings/);
assert.match(quickstart, /Install `uv`, run `ktx runtime doctor`/);
assert.match(packageArtifacts, /requires `uv` on `PATH`/);
assert.match(packageArtifacts, /ktx runtime status/);
assert.match(packageArtifacts, /ktx runtime doctor/);
assert.match(packageArtifacts, /ktx runtime prune --dry-run/);
assert.match(packageArtifacts, /ktx runtime prune --yes/);
assert.match(
rootReadme,
runtimeWheelPackagePattern(
'release\\s+artifact manifest contains the public npm tarball and the\\s+bundled `{package}`\\s+runtime wheel',
packageArtifacts,
new RegExp(
`artifact manifest contains the public \`${escapeRegExp(publicNpmPackageName())}\` npm tarball and the\\s+bundled \`${escapeRegExp(
runtimeWheelPackageName(),
)}\` runtime wheel`,
),
);
assert.match(rootReadme, /source packages for\s+development, not public release artifacts/);
assert.match(rootReadme, /ktx serve --mcp stdio/);
assert.doesNotMatch(rootReadme, /uv run ktx-daemon serve-http/);
assert.doesNotMatch(rootReadme, /--semantic-compute-url http:\/\/127\.0\.0\.1:8765/);
@ -232,14 +246,17 @@ describe('standalone example docs', () => {
assert.doesNotMatch(readme, /python -m ktx_daemon semantic-validate/);
});
it('replaces the fake-ingest smoke with a ktx scan walkthrough in the README', async () => {
it('documents scan workflows in the docs site', async () => {
const rootReadme = await readText('README.md');
const buildingContext = await readText('docs-site/content/docs/guides/building-context.mdx');
const scanReference = await readText('docs-site/content/docs/cli-reference/ktx-scan.mdx');
assert.match(rootReadme, /### Scan the demo warehouse/);
assert.match(rootReadme, /scan warehouse --project-dir/);
assert.match(rootReadme, /scan status --project-dir/);
assert.match(rootReadme, /scan report --project-dir/);
assert.match(rootReadme, /raw-sources\/warehouse\/live-database/);
assert.match(buildingContext, /ktx dev scan <connection-id>/);
assert.match(buildingContext, /ktx dev scan status <run-id>/);
assert.match(buildingContext, /ktx dev scan report <run-id>/);
assert.match(scanReference, /ktx dev scan <connectionId> \[options\]/);
assert.match(rootReadme, /raw-sources\//);
assert.match(rootReadme, /live-database\//);
assert.doesNotMatch(rootReadme, /Run a local ingest smoke test/);
assert.doesNotMatch(rootReadme, /ktx dev ingest run --project-dir/);
assert.doesNotMatch(rootReadme, /ktx ingest status --project-dir/);

View file

@ -62,7 +62,7 @@ function firstNonEmptyLine(...values) {
function parseArgs(argv) {
const options = {
connectionId: process.env.KTX_ORBIT_CONNECTION_ID ?? 'orbit',
projectDir: process.env.KTX_ORBIT_PROJECT_DIR ?? defaultProjectDir,
projectDir: process.env.KTX_PROJECT_DIR ?? defaultProjectDir,
reportPath: defaultReportPath,
};
@ -242,7 +242,7 @@ function orbitVerificationEnv(projectDir) {
export async function runOrbitVerification(options = {}) {
const connectionId = options.connectionId ?? process.env.KTX_ORBIT_CONNECTION_ID ?? 'orbit';
const projectDir = options.projectDir ?? process.env.KTX_ORBIT_PROJECT_DIR ?? defaultProjectDir;
const projectDir = options.projectDir ?? process.env.KTX_PROJECT_DIR ?? defaultProjectDir;
const reportPath = options.reportPath ?? defaultReportPath;
const rootDir = options.rootDir ?? ktxRootDir;
const runner = options.runWorkspaceKtx ?? runWorkspaceKtx;

View file

@ -115,6 +115,43 @@ describe('relationship Orbit verification helper', () => {
assert.match(writes[0].content, new RegExp(defaultProjectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});
it('uses KTX_PROJECT_DIR for the Orbit verification project override', async () => {
const previousProjectDir = process.env.KTX_PROJECT_DIR;
const calls = [];
try {
process.env.KTX_PROJECT_DIR = '/tmp/orbit-project-from-env';
const result = await runOrbitVerification({
reportPath: '/tmp/orbit-report.md',
now: () => new Date('2026-05-07T10:00:00.000Z'),
mkdir: async () => {},
writeFile: async () => {},
runWorkspaceKtx: async (argv, options) => {
calls.push(argv);
if (argv[2] === 'report') {
options.stdout.write(successReportJson());
return 0;
}
options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n');
return 0;
},
});
assert.equal(result.projectDir, '/tmp/orbit-project-from-env');
assert.deepEqual(calls, [
['dev', 'scan', 'orbit', '--enrich', '--project-dir', '/tmp/orbit-project-from-env'],
['dev', 'scan', 'report', '--json', '--project-dir', '/tmp/orbit-project-from-env', 'scan-orbit-1'],
]);
} finally {
if (previousProjectDir === undefined) {
delete process.env.KTX_PROJECT_DIR;
} else {
process.env.KTX_PROJECT_DIR = previousProjectDir;
}
}
});
it('extracts the run id from human scan output', () => {
assert.equal(extractRunId(`KTX scan completed\nStatus: done\nRun: scan-orbit-1\nConnection: orbit\n`), 'scan-orbit-1');
assert.equal(extractRunId('KTX scan completed without a run line\n'), null);