diff --git a/.gitignore b/.gitignore index eb63517a..ed14196b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ yarn-error.log* # Local project runtime state .ktx/ +**/.devtools/ *.db *.sqlite *.sqlite3 diff --git a/README.md b/README.md index 84592226..d4e9c5d9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- KTX + KTX

@@ -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//` 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. diff --git a/assets/ktx-lockup.svg b/assets/ktx-lockup.svg new file mode 100644 index 00000000..f1bcd2dd --- /dev/null +++ b/assets/ktx-lockup.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + ktx + diff --git a/assets/ktx-readme-header.png b/assets/ktx-readme-header.png deleted file mode 100644 index 11cfb4e4..00000000 Binary files a/assets/ktx-readme-header.png and /dev/null differ diff --git a/docs-site/content/docs/integrations/primary-sources.mdx b/docs-site/content/docs/integrations/primary-sources.mdx index c36260d1..dcfd143f 100644 --- a/docs-site/content/docs/integrations/primary-sources.mdx +++ b/docs-site/content/docs/integrations/primary-sources.mdx @@ -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. diff --git a/examples/orbit-relationship-verification/README.md b/examples/orbit-relationship-verification/README.md index 245411b6..126488a2 100644 --- a/examples/orbit-relationship-verification/README.md +++ b/examples/orbit-relationship-verification/README.md @@ -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 ``` diff --git a/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md b/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md index 5e827355..2059329d 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md @@ -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) --- diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md index 7998c35a..dc97bdc2 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md @@ -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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md index 675abb63..3216d94d 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md @@ -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 diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md index ac8cd076..4cb34b76 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md @@ -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 diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md index 6cf4afac..83645aeb 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md @@ -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." - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md index 4457bd21..56deb3f3 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md @@ -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 diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md index 3822e31e..e98c1663 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md @@ -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`. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md index 981d494c..05011e48 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md @@ -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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md index 11aac427..41179bc2 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md @@ -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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md index 29ba3392..74a936f6 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md @@ -20,7 +20,7 @@ tables: -**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 (0–1); multiply by 100 for percentage display. - See [orbit-activation-policy-change-jan-2026](orbit-activation-policy-change-jan-2026) for full policy context. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md index d23ee684..04085359 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md @@ -19,7 +19,7 @@ tables: -**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). - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md index f1231a30..5b3db7dd 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md @@ -18,7 +18,7 @@ tables: -**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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md index 9b423e6d..288c8201 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md @@ -20,7 +20,7 @@ tables: -**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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md index 0b31edae..ab3de364 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md @@ -18,7 +18,7 @@ tables: -**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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md index d5021f0a..7c22cf16 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md @@ -19,7 +19,7 @@ tables: -**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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md index 6a088de0..8deb5ffe 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md @@ -20,7 +20,7 @@ tables: -**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. - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md index d94ba88b..28055b9b 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md @@ -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 | - diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md index 4af79d78..0b966d8c 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md @@ -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 diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md index aeab9be3..580c1fa1 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md @@ -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 diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md index 87cc64c5..08126dd5 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md @@ -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 diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md index 7de4138b..65004331 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md @@ -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 diff --git a/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md b/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md index 13eb139f..d547d026 100644 --- a/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md +++ b/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md @@ -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) --- diff --git a/packages/cli/src/commands/connection-metabase-setup.test.ts b/packages/cli/src/commands/connection-metabase-setup.test.ts index cd94565a..cf7308d7 100644 --- a/packages/cli/src/commands/connection-metabase-setup.test.ts +++ b/packages/cli/src/commands/connection-metabase-setup.test.ts @@ -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; diff --git a/packages/cli/src/commands/runtime-commands.ts b/packages/cli/src/commands/runtime-commands.ts index 8f478658..3ce7d9ba 100644 --- a/packages/cli/src/commands/runtime-commands.ts +++ b/packages/cli/src/commands/runtime-commands.ts @@ -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, }); }); diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts index ae593805..04c73cf1 100644 --- a/packages/cli/src/connection.test.ts +++ b/packages/cli/src/connection.test.ts @@ -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'], diff --git a/packages/cli/src/demo.test.ts b/packages/cli/src/demo.test.ts index 0cedba99..0b053ee6 100644 --- a/packages/cli/src/demo.test.ts +++ b/packages/cli/src/demo.test.ts @@ -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 () => { diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 8ee74016..87a0089f 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -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'], diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 96fbbeec..de906ece 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 { diff --git a/packages/cli/src/managed-python-daemon.test.ts b/packages/cli/src/managed-python-daemon.test.ts index 4e7af22c..ffa69972 100644 --- a/packages/cli/src/managed-python-daemon.test.ts +++ b/packages/cli/src/managed-python-daemon.test.ts @@ -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 }; } +function daemonStatePath(root: string, version: string): string { + return join(root, 'runtime', version, 'daemon.json'); +} + +function runningStateForVersion( + root: string, + version: string, + overrides: Partial = {}, +): 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 => [ + { 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 => [ + { 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'); + }); }); diff --git a/packages/cli/src/managed-python-daemon.ts b/packages/cli/src/managed-python-daemon.ts index 2caf9182..b99de581 100644 --- a/packages/cli/src/managed-python-daemon.ts +++ b/packages/cli/src/managed-python-daemon.ts @@ -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; }>; +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; 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; + 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 { 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 { + 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).status === 'healthy' ? 'healthy' : 'unreachable'; + } catch { + return 'unreachable'; + } finally { + clearTimeout(timeout); + } +} + +async function readStateCandidates(runtimeRoot: string): Promise { + 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(); + 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; + 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 { + 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 { + 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 { @@ -404,3 +765,63 @@ export async function stopManagedPythonDaemon( }); return { status: 'stopped', layout, state }; } + +export async function stopAllManagedPythonDaemons( + options: ManagedPythonDaemonStopAllOptions, +): Promise { + 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, + }; +} diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index e367d339..46f708b2 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -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 => ({ + 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 => ({ + 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 = { diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index fe2b5f74..e88f2b31 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -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; stopDaemon?: (options: { cliVersion: string }) => Promise; + stopAllDaemons?: (options: { cliVersion: string }) => Promise; readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise; 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; diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index cd6e9a9f..58efc506 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -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(); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 47a7997a..a4f081d0 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -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>['c .sort((left, right) => left.connectionId.localeCompare(right.connectionId)); } +type LocalIngestStatusReport = NonNullable>>; + +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 { + 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 { const resolvedProjectDir = resolve(projectDir); if (!existsSync(join(resolvedProjectDir, 'ktx.yaml'))) { @@ -279,6 +305,10 @@ export async function readKtxSetupStatus(projectDir: string): Promise { 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', }); }); diff --git a/packages/context/src/connections/notion-config.test.ts b/packages/context/src/connections/notion-config.test.ts index 33d1e110..8ad88c86 100644 --- a/packages/context/src/connections/notion-config.test.ts +++ b/packages/context/src/connections/notion-config.test.ts @@ -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({ diff --git a/packages/context/src/ingest/local-stage-ingest.test.ts b/packages/context/src/ingest/local-stage-ingest.test.ts index e24174fb..157bd96b 100644 --- a/packages/context/src/ingest/local-stage-ingest.test.ts +++ b/packages/context/src/ingest/local-stage-ingest.test.ts @@ -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; } } }); diff --git a/packages/context/src/memory/memory-agent.service.ingest.test.ts b/packages/context/src/memory/memory-agent.service.ingest.test.ts index 710ba956..6375e494 100644 --- a/packages/context/src/memory/memory-agent.service.ingest.test.ts +++ b/packages/context/src/memory/memory-agent.service.ingest.test.ts @@ -37,6 +37,7 @@ interface BuiltMocks { agentRunner: any; slValidator: any; toolsetFactory: any; + logger: any; } const buildMocks = (overrides: Partial = {}): BuiltMocks => { @@ -131,6 +132,7 @@ const buildMocks = (overrides: Partial = {}): 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({ diff --git a/packages/context/src/memory/memory-agent.service.ts b/packages/context/src/memory/memory-agent.service.ts index fd1f0a6c..6f239053 100644 --- a/packages/context/src/memory/memory-agent.service.ts +++ b/packages/context/src/memory/memory-agent.service.ts @@ -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}`); } diff --git a/packages/llm/package.json b/packages/llm/package.json index fc7deeba..13f49666 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -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" diff --git a/packages/llm/src/model-health.test.ts b/packages/llm/src/model-health.test.ts index 003c12d5..d1b3df47 100644 --- a/packages/llm/src/model-health.test.ts +++ b/packages/llm/src/model-health.test.ts @@ -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 () => { diff --git a/packages/llm/src/model-health.ts b/packages/llm/src/model-health.ts index 131822b6..abbc2735 100644 --- a/packages/llm/src/model-health.ts +++ b/packages/llm/src/model-health.ts @@ -41,7 +41,7 @@ export async function runKtxLlmHealthCheck( ): Promise { 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({ diff --git a/packages/llm/src/model-provider.test.ts b/packages/llm/src/model-provider.test.ts index 55dd5da9..ff65a12a 100644 --- a/packages/llm/src/model-provider.test.ts +++ b/packages/llm/src/model-provider.test.ts @@ -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 => ({ specificationVersion: 'v3' }); +const wrapWith = (model: LanguageModel) => + vi.fn((_options: Parameters[0]) => model as ReturnType); 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); diff --git a/packages/llm/src/model-provider.ts b/packages/llm/src/model-provider.ts index 66a9fddd..6dbdcb06 100644 --- a/packages/llm/src/model-provider.ts +++ b/packages/llm/src/model-provider.ts @@ -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): 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[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)({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e74389f9..0cd6dbeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/examples-docs.test.mjs b/scripts/examples-docs.test.mjs index 24c83452..81c42b9c 100644 --- a/scripts/examples-docs.test.mjs +++ b/scripts/examples-docs.test.mjs @@ -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 /); + assert.match(buildingContext, /ktx dev scan status /); + assert.match(buildingContext, /ktx dev scan report /); + assert.match(scanReference, /ktx dev scan \[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/); diff --git a/scripts/relationship-orbit-verification.mjs b/scripts/relationship-orbit-verification.mjs index 1c24a4e9..d1c97f56 100644 --- a/scripts/relationship-orbit-verification.mjs +++ b/scripts/relationship-orbit-verification.mjs @@ -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; diff --git a/scripts/relationship-orbit-verification.test.mjs b/scripts/relationship-orbit-verification.test.mjs index c7cdaffc..017b2518 100644 --- a/scripts/relationship-orbit-verification.test.mjs +++ b/scripts/relationship-orbit-verification.test.mjs @@ -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);