mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Merge branch 'main' into andreybavt/execute-context7-plan
This commit is contained in:
commit
15f433930e
54 changed files with 1385 additions and 173 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -43,6 +43,7 @@ yarn-error.log*
|
|||
|
||||
# Local project runtime state
|
||||
.ktx/
|
||||
**/.devtools/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
|
|
|||
114
README.md
114
README.md
|
|
@ -1,5 +1,5 @@
|
|||
<h1 align="center">
|
||||
<img src="assets/ktx-readme-header.png" alt="KTX" width="472" />
|
||||
<img src="assets/ktx-lockup.svg" alt="KTX" width="500" />
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -43,6 +43,7 @@ SQLite.
|
|||
Install the CLI and run the setup wizard:
|
||||
|
||||
```bash
|
||||
npm install @kaelio/ktx
|
||||
npm install -g @kaelio/ktx
|
||||
ktx setup
|
||||
```
|
||||
|
|
@ -70,6 +71,40 @@ KTX context built: yes
|
|||
Agent integration ready: yes (claude-code:project)
|
||||
```
|
||||
|
||||
Run the packaged demo without installing globally:
|
||||
|
||||
```bash
|
||||
npx @kaelio/ktx setup demo --no-input
|
||||
npx @kaelio/ktx setup demo inspect
|
||||
```
|
||||
|
||||
The default demo uses packaged sample data and prebuilt context. It does not
|
||||
require API keys, network access, or an LLM provider.
|
||||
|
||||
Generate SQL from a semantic-layer source:
|
||||
|
||||
```bash
|
||||
npx @kaelio/ktx sl query --project-dir "$PROJECT_DIR" \
|
||||
--connection-id warehouse \
|
||||
--measure accounts.account_count \
|
||||
--dimension accounts.segment \
|
||||
--format sql
|
||||
```
|
||||
|
||||
List and test a configured warehouse connection:
|
||||
|
||||
```bash
|
||||
ktx connection list --project-dir "$PROJECT_DIR"
|
||||
ktx connection test warehouse --project-dir "$PROJECT_DIR"
|
||||
```
|
||||
|
||||
The connection test prints the configured driver and discovered table count:
|
||||
|
||||
```text
|
||||
Driver: sqlite
|
||||
Tables: 1
|
||||
```
|
||||
|
||||
## What's in a project
|
||||
|
||||
```
|
||||
|
|
@ -97,6 +132,47 @@ Semantic sources and knowledge pages are committed to git. The `.ktx/` directory
|
|||
holds ephemeral state and is git-ignored — delete it and KTX rebuilds on the
|
||||
next run.
|
||||
|
||||
### Scan the demo warehouse
|
||||
|
||||
Scan artifacts are written under
|
||||
`raw-sources/warehouse/live-database/<syncId>/` in the project directory.
|
||||
|
||||
```bash
|
||||
SCAN_OUTPUT="$(ktx scan warehouse --project-dir "$PROJECT_DIR")"
|
||||
printf '%s\n' "$SCAN_OUTPUT"
|
||||
SCAN_RUN_ID="$(printf '%s\n' "$SCAN_OUTPUT" | awk '/^Run: / { print $2 }')"
|
||||
ktx scan status --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
|
||||
ktx scan report --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
|
||||
```
|
||||
|
||||
For non-SQLite drivers, prefer credential references such as `--url env:NAME`
|
||||
or `--url file:PATH` over literal credential URLs.
|
||||
|
||||
## Managed Python runtime
|
||||
|
||||
KTX installs its Python runtime only when a Python-backed command needs it.
|
||||
The runtime lives outside the npm cache, is versioned by the installed CLI
|
||||
version, and is managed by `ktx runtime` commands.
|
||||
|
||||
KTX requires `uv` on `PATH` to create the managed runtime. Install `uv` with
|
||||
your system package manager or the official installer before running Python-
|
||||
backed KTX commands. KTX doesn't download `uv` automatically; run
|
||||
`ktx runtime doctor` if runtime installation fails:
|
||||
|
||||
```bash
|
||||
ktx runtime install --yes
|
||||
ktx runtime status
|
||||
ktx runtime doctor
|
||||
ktx runtime start
|
||||
ktx runtime stop
|
||||
ktx runtime prune --dry-run
|
||||
ktx runtime prune --yes
|
||||
```
|
||||
|
||||
The release artifact manifest contains the public npm tarball and the bundled `kaelio-ktx`
|
||||
runtime wheel. The `python/ktx-sl` and `python/ktx-daemon` directories remain
|
||||
source packages for development, not public release artifacts.
|
||||
|
||||
## Serve agents
|
||||
|
||||
KTX integrates with coding agents through CLI skills, an MCP server, or both.
|
||||
|
|
@ -126,6 +202,11 @@ This exposes tools for connections, knowledge search, semantic-layer sources,
|
|||
validation, queries, ingestion, and replay. The `--semantic-compute` flag starts
|
||||
the managed Python runtime for query planning automatically.
|
||||
|
||||
The standalone MCP server exposes `connection_list`, `knowledge_search`,
|
||||
`knowledge_read`, `knowledge_write`, `sl_list_sources`, `sl_read_source`,
|
||||
`sl_write_source`, `sl_validate`, `sl_query`, `ingest_trigger`,
|
||||
`ingest_status`, `ingest_report`, and `ingest_replay`.
|
||||
|
||||
Supported agents: Claude Code, Codex, Cursor, OpenCode, and any agent that
|
||||
reads `.agents/` skills or MCP configuration.
|
||||
|
||||
|
|
@ -136,7 +217,13 @@ reads `.agents/` skills or MCP configuration.
|
|||
| `packages/cli` | CLI entry point |
|
||||
| `packages/context` | Core context engine |
|
||||
| `packages/llm` | LLM and embedding providers |
|
||||
| `packages/connector-*` | Database connectors (Postgres, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server, SQLite) |
|
||||
| `packages/connector-bigquery` | BigQuery scan connector |
|
||||
| `packages/connector-clickhouse` | ClickHouse scan connector |
|
||||
| `packages/connector-mysql` | MySQL scan connector |
|
||||
| `packages/connector-postgres` | Postgres scan connector |
|
||||
| `packages/connector-snowflake` | Snowflake scan connector |
|
||||
| `packages/connector-sqlite` | SQLite scan connector |
|
||||
| `packages/connector-sqlserver` | SQL Server scan connector |
|
||||
| `python/ktx-sl` | Semantic-layer query planning |
|
||||
| `python/ktx-daemon` | Portable compute service |
|
||||
|
||||
|
|
@ -159,6 +246,29 @@ pnpm run link:dev
|
|||
ktx-dev --help
|
||||
```
|
||||
|
||||
### Debug LLM traces
|
||||
|
||||
KTX can capture local AI SDK DevTools traces for LLM calls that run through the
|
||||
KTX provider. Enable it with an environment flag when running an LLM-backed
|
||||
command:
|
||||
|
||||
```bash
|
||||
KTX_AI_DEVTOOLS_ENABLED=true ktx dev ingest run \
|
||||
--connection-id warehouse \
|
||||
--adapter metabase
|
||||
```
|
||||
|
||||
Traces are written to `.devtools/generations.json` under the current working
|
||||
directory. To inspect them, run:
|
||||
|
||||
```bash
|
||||
pnpm dlx @ai-sdk/devtools
|
||||
```
|
||||
|
||||
Then open `http://localhost:4983`. These traces are local-development-only and
|
||||
store prompts, model outputs, tool arguments/results, and raw provider payloads
|
||||
in plain text. Do not enable this in production or for sensitive runs.
|
||||
|
||||
The repository uses `pnpm` for TypeScript packages and `uv` for Python
|
||||
packages. See [Contributing](docs-site/content/docs/community/contributing.mdx)
|
||||
for full development setup, testing, and PR guidelines.
|
||||
|
|
|
|||
32
assets/ktx-lockup.svg
Normal file
32
assets/ktx-lockup.svg
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<svg viewBox="0 0 500 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ktx">
|
||||
<!-- mascot -->
|
||||
<g fill="none" stroke="#1B3139" stroke-width="16" stroke-linecap="round">
|
||||
<path d="M 62 110 Q 32 130 44 152" />
|
||||
<path d="M 88 116 Q 80 152 70 174" />
|
||||
<path d="M 112 116 Q 120 152 130 174" />
|
||||
</g>
|
||||
|
||||
<path
|
||||
d="M 134 108 C 162 116, 172 96, 162 78 C 154 64, 168 56, 178 60"
|
||||
fill="none" stroke="#FF8A4C" stroke-width="16" stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<path
|
||||
d="M 48 102 C 48 56, 78 30, 100 30 C 122 30, 152 56, 152 102 C 152 116, 132 120, 100 120 C 68 120, 48 116, 48 102 Z"
|
||||
fill="#1B3139"
|
||||
/>
|
||||
|
||||
<path d="M 80 84 Q 86 77 92 84" fill="none" stroke="#F5F1EA" stroke-width="3.5" stroke-linecap="round" />
|
||||
<path d="M 108 84 Q 114 77 120 84" fill="none" stroke="#F5F1EA" stroke-width="3.5" stroke-linecap="round" />
|
||||
|
||||
<!-- wordmark: 'ktx', half the logo height, vertically centered -->
|
||||
<text
|
||||
x="225"
|
||||
y="145"
|
||||
font-family="'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, monospace"
|
||||
font-size="140"
|
||||
font-weight="600"
|
||||
fill="#1B3139"
|
||||
letter-spacing="-0.04em"
|
||||
>ktx</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ tables:
|
|||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/106.json -->
|
||||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/107.json -->
|
||||
|
||||
**Table:** `orbit_analytics.mart_account_activity`
|
||||
**Table:** `orbit_analytics.mart_account_activity`
|
||||
**Grain:** one row per `policy_change_date`
|
||||
|
||||
## Columns
|
||||
|
|
@ -47,4 +47,3 @@ tables:
|
|||
- The January 2026 activation policy change (`policy_change_date = 2026-01-15`) is the primary boundary. `policy_version` in upstream events splits into `pre_2026_01_15` and `post_2026_01_15` cohorts.
|
||||
- Rates are ratios (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.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ tables:
|
|||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/69.json -->
|
||||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/100.json -->
|
||||
|
||||
**Table:** `orbit_analytics.mart_account_segments`
|
||||
**Table:** `orbit_analytics.mart_account_segments`
|
||||
**Grain:** one row per `account_id`
|
||||
|
||||
## Columns
|
||||
|
|
@ -53,4 +53,3 @@ tables:
|
|||
- `normalized_plan_code` maps `pro_plus` → `growth`. Always use `normalized_plan_code` for plan-based reporting. See [orbit-plan-segment-normalization](orbit-plan-segment-normalization).
|
||||
- `segment` is derived from `canonical_plan_code × size_band` via `stg_plan_segment_mapping`.
|
||||
- `contract_arr_cents` is the contract-first ARR value. See [orbit-arr-contract-first-definition](orbit-arr-contract-first-definition).
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ tables:
|
|||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/56.json -->
|
||||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/96.json -->
|
||||
|
||||
**Table:** `orbit_analytics.mart_arr_daily`
|
||||
**Table:** `orbit_analytics.mart_arr_daily`
|
||||
**Grain:** one row per `metric_date`
|
||||
|
||||
## Columns
|
||||
|
|
@ -44,4 +44,3 @@ tables:
|
|||
|
||||
- ARR is calculated contract-first: active contract ARR takes precedence over subscription ARR for any covered period. See [orbit-arr-contract-first-definition](orbit-arr-contract-first-definition).
|
||||
- `display` is a formatted label for UI rendering; use `arr_cents` for all arithmetic.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ tables:
|
|||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/98.json -->
|
||||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/103.json -->
|
||||
|
||||
**Table:** `orbit_analytics.mart_nrr_quarterly`
|
||||
**Table:** `orbit_analytics.mart_nrr_quarterly`
|
||||
**Grain:** one row per `quarter_label` × `segment`
|
||||
|
||||
## Columns
|
||||
|
|
@ -53,4 +53,3 @@ tables:
|
|||
- `net_revenue_retention` is a ratio, not a percentage. Multiply by 100 for display.
|
||||
- Contraction includes discount expirations (classified as contraction, not churn). See [orbit-nrr-discount-expiration-treatment](orbit-nrr-discount-expiration-treatment).
|
||||
- Enterprise is the primary executive reporting segment.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ tables:
|
|||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/88.json -->
|
||||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/108.json -->
|
||||
|
||||
**Table:** `orbit_analytics.mart_procurement_activity`
|
||||
**Table:** `orbit_analytics.mart_procurement_activity`
|
||||
**Grain:** one row per `week_start_date` × `contract_arr_threshold_cents`
|
||||
|
||||
## Columns
|
||||
|
|
@ -45,4 +45,3 @@ tables:
|
|||
- `active_requesters` counts non-internal, non-test requesters on large active contracts. See [orbit-procurement-qualifying-actions](orbit-procurement-qualifying-actions).
|
||||
- The standard threshold is `contract_arr_threshold_cents = 20000000` ($200k ARR).
|
||||
- Always filter by `contract_arr_threshold_cents` — the table contains rows for multiple threshold values.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ tables:
|
|||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/105.json -->
|
||||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/115.json -->
|
||||
|
||||
**Table:** `orbit_analytics.mart_retention_movement_breakout`
|
||||
**Table:** `orbit_analytics.mart_retention_movement_breakout`
|
||||
**Grain:** one row per `quarter_label` × `segment` × `movement_type` × `movement_reason`
|
||||
|
||||
## Columns
|
||||
|
|
@ -53,4 +53,3 @@ tables:
|
|||
- Contraction includes discount expirations, classified as contraction (not churn), tracked via `movement_reason`. See [orbit-nrr-discount-expiration-treatment](orbit-nrr-discount-expiration-treatment).
|
||||
- This table is the row-level source for `mart_nrr_quarterly` aggregations.
|
||||
- Only one of `expansion_arr_cents`, `contraction_arr_cents`, `churned_arr_cents` is non-zero per row.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ tables:
|
|||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/102.json -->
|
||||
<!-- from: raw-sources/postgres-warehouse/metabase/2026-05-12-035303-local-metabase-3-114d957b-f564-4f46-8d4c-2770720a95be/cards/104.json -->
|
||||
|
||||
**Table:** `orbit_analytics.mart_revenue_daily`
|
||||
**Table:** `orbit_analytics.mart_revenue_daily`
|
||||
**Grain:** one row per `revenue_date`
|
||||
|
||||
## Columns
|
||||
|
|
@ -54,4 +54,3 @@ tables:
|
|||
- `reconciliation_check` must be `true` on every row. Any `false` row indicates a data quality issue.
|
||||
- Gross-to-net reconciliation: gross revenue − credits − refunds = net revenue. See [orbit-revenue-gross-to-net-reconciliation](orbit-revenue-gross-to-net-reconciliation).
|
||||
- All amounts are in cents; divide by 100 for USD, by 100,000,000 for $M.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
readManagedPythonDaemonStatus,
|
||||
startManagedPythonDaemon,
|
||||
stopAllManagedPythonDaemons,
|
||||
stopManagedPythonDaemon,
|
||||
type ManagedPythonDaemonChild,
|
||||
type ManagedPythonDaemonFetch,
|
||||
type ManagedPythonDaemonProcessInfo,
|
||||
type ManagedPythonDaemonSpawn,
|
||||
type ManagedPythonDaemonState,
|
||||
} from './managed-python-daemon.js';
|
||||
|
|
@ -105,6 +107,24 @@ function runningState(root: string, overrides: Partial<ManagedPythonDaemonState>
|
|||
};
|
||||
}
|
||||
|
||||
function daemonStatePath(root: string, version: string): string {
|
||||
return join(root, 'runtime', version, 'daemon.json');
|
||||
}
|
||||
|
||||
function runningStateForVersion(
|
||||
root: string,
|
||||
version: string,
|
||||
overrides: Partial<ManagedPythonDaemonState> = {},
|
||||
): ManagedPythonDaemonState {
|
||||
return {
|
||||
...runningState(root),
|
||||
version,
|
||||
stdoutLog: join(root, 'runtime', version, 'daemon.stdout.log'),
|
||||
stderrLog: join(root, 'runtime', version, 'daemon.stderr.log'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('managed Python daemon lifecycle', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -271,4 +291,138 @@ describe('managed Python daemon lifecycle', () => {
|
|||
expect(killProcess).toHaveBeenCalledWith(4242);
|
||||
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('stops all recorded daemon states across runtime versions and removes state files', async () => {
|
||||
await mkdir(join(tempDir, 'runtime', '0.1.0'), { recursive: true });
|
||||
await mkdir(join(tempDir, 'runtime', '0.2.0'), { recursive: true });
|
||||
await writeFile(
|
||||
daemonStatePath(tempDir, '0.1.0'),
|
||||
`${JSON.stringify(runningStateForVersion(tempDir, '0.1.0', { pid: 1111, port: 61111 }), null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
daemonStatePath(tempDir, '0.2.0'),
|
||||
`${JSON.stringify(runningStateForVersion(tempDir, '0.2.0', { pid: 2222, port: 62222 }), null, 2)}\n`,
|
||||
);
|
||||
const alive = new Set([1111, 2222]);
|
||||
const killProcess = vi.fn((pid: number) => {
|
||||
alive.delete(pid);
|
||||
});
|
||||
|
||||
const result = await stopAllManagedPythonDaemons({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
processAlive: vi.fn((pid) => alive.has(pid)),
|
||||
killProcess,
|
||||
stopGraceMs: 0,
|
||||
});
|
||||
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(result.stopped.map((entry) => entry.pid).sort()).toEqual([1111, 2222]);
|
||||
expect(killProcess).toHaveBeenCalledWith(1111, 'SIGTERM');
|
||||
expect(killProcess).toHaveBeenCalledWith(2222, 'SIGTERM');
|
||||
await expect(readFile(daemonStatePath(tempDir, '0.1.0'), 'utf8')).rejects.toThrow();
|
||||
await expect(readFile(daemonStatePath(tempDir, '0.2.0'), 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('removes stale state when the recorded daemon process is no longer alive', async () => {
|
||||
await mkdir(layout(tempDir).versionDir, { recursive: true });
|
||||
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
|
||||
|
||||
const result = await stopAllManagedPythonDaemons({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
processAlive: vi.fn(() => false),
|
||||
killProcess: vi.fn(),
|
||||
stopGraceMs: 0,
|
||||
});
|
||||
|
||||
expect(result.stopped).toHaveLength(0);
|
||||
expect(result.stale.map((entry) => entry.pid)).toEqual([4242]);
|
||||
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('deduplicates a daemon found by state and process scan, preferring state metadata', async () => {
|
||||
await mkdir(layout(tempDir).versionDir, { recursive: true });
|
||||
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
|
||||
const alive = new Set([4242]);
|
||||
const killProcess = vi.fn((pid: number) => {
|
||||
alive.delete(pid);
|
||||
});
|
||||
|
||||
const result = await stopAllManagedPythonDaemons({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
listProcesses: vi.fn(async (): Promise<ManagedPythonDaemonProcessInfo[]> => [
|
||||
{ pid: 4242, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 61234' },
|
||||
]),
|
||||
processAlive: vi.fn((pid) => alive.has(pid)),
|
||||
killProcess,
|
||||
stopGraceMs: 0,
|
||||
});
|
||||
|
||||
expect(result.stopped).toHaveLength(1);
|
||||
expect(result.stopped[0]).toMatchObject({
|
||||
pid: 4242,
|
||||
source: 'state',
|
||||
url: 'http://127.0.0.1:58731',
|
||||
});
|
||||
expect(killProcess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops unrecorded ktx-daemon serve-http processes from process scan results', async () => {
|
||||
const alive = new Set([3333, 5555]);
|
||||
const killProcess = vi.fn((pid: number) => {
|
||||
alive.delete(pid);
|
||||
});
|
||||
|
||||
const result = await stopAllManagedPythonDaemons({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
listProcesses: vi.fn(async (): Promise<ManagedPythonDaemonProcessInfo[]> => [
|
||||
{ pid: 3333, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765' },
|
||||
{ pid: 4444, command: 'node server.js --port 8765' },
|
||||
{ pid: 5555, command: 'grep ktx-daemon serve-http --port 8765' },
|
||||
]),
|
||||
processAlive: vi.fn((pid) => alive.has(pid)),
|
||||
killProcess,
|
||||
stopGraceMs: 0,
|
||||
});
|
||||
|
||||
expect(result.failed).toHaveLength(0);
|
||||
expect(result.stopped).toEqual([
|
||||
expect.objectContaining({
|
||||
pid: 3333,
|
||||
source: 'process',
|
||||
url: 'http://127.0.0.1:8765',
|
||||
}),
|
||||
]);
|
||||
expect(killProcess).toHaveBeenCalledWith(3333, 'SIGTERM');
|
||||
expect(killProcess).not.toHaveBeenCalledWith(4444, expect.anything());
|
||||
expect(killProcess).not.toHaveBeenCalledWith(5555, expect.anything());
|
||||
});
|
||||
|
||||
it('reports a failed stop when TERM and KILL leave a daemon running', async () => {
|
||||
await mkdir(layout(tempDir).versionDir, { recursive: true });
|
||||
await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
|
||||
|
||||
const result = await stopAllManagedPythonDaemons({
|
||||
cliVersion: '0.2.0',
|
||||
runtimeRoot: join(tempDir, 'runtime'),
|
||||
listProcesses: vi.fn(async () => []),
|
||||
processAlive: vi.fn(() => true),
|
||||
killProcess: vi.fn(),
|
||||
stopGraceMs: 0,
|
||||
});
|
||||
|
||||
expect(result.stopped).toHaveLength(0);
|
||||
expect(result.failed).toEqual([
|
||||
expect.objectContaining({
|
||||
pid: 4242,
|
||||
detail: 'Process still running after SIGKILL',
|
||||
}),
|
||||
]);
|
||||
expect(await readFile(layout(tempDir).daemonStatePath, 'utf8')).toContain('"pid": 4242');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { mkdir, open, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
installManagedPythonRuntime,
|
||||
|
|
@ -44,6 +46,35 @@ export interface ManagedPythonDaemonStopResult {
|
|||
state?: ManagedPythonDaemonState;
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonProcessInfo {
|
||||
pid: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export type ManagedPythonDaemonStopAllSource = 'state' | 'process';
|
||||
|
||||
export interface ManagedPythonDaemonStopAllEntry {
|
||||
pid: number;
|
||||
source: ManagedPythonDaemonStopAllSource;
|
||||
url?: string;
|
||||
health?: 'healthy' | 'unreachable';
|
||||
version?: string;
|
||||
command?: string;
|
||||
statePaths: string[];
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonStopAllFailure extends ManagedPythonDaemonStopAllEntry {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonStopAllResult {
|
||||
runtimeRoot: string;
|
||||
stopped: ManagedPythonDaemonStopAllEntry[];
|
||||
stale: ManagedPythonDaemonStopAllEntry[];
|
||||
failed: ManagedPythonDaemonStopAllFailure[];
|
||||
scanErrors: string[];
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonChild {
|
||||
pid?: number;
|
||||
unref(): void;
|
||||
|
|
@ -68,6 +99,8 @@ export type ManagedPythonDaemonFetch = (
|
|||
text(): Promise<string>;
|
||||
}>;
|
||||
|
||||
export type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void;
|
||||
|
||||
export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions {
|
||||
features: KtxRuntimeFeature[];
|
||||
force?: boolean;
|
||||
|
|
@ -76,7 +109,7 @@ export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLay
|
|||
fetch?: ManagedPythonDaemonFetch;
|
||||
allocatePort?: () => Promise<number>;
|
||||
processAlive?: (pid: number) => boolean;
|
||||
killProcess?: (pid: number) => void;
|
||||
killProcess?: ManagedPythonDaemonKillProcess;
|
||||
now?: () => Date;
|
||||
startupTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
|
|
@ -89,9 +122,20 @@ export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLa
|
|||
|
||||
export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions {
|
||||
processAlive?: (pid: number) => boolean;
|
||||
killProcess?: (pid: number) => void;
|
||||
killProcess?: ManagedPythonDaemonKillProcess;
|
||||
}
|
||||
|
||||
export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonRuntimeLayoutOptions {
|
||||
listProcesses?: () => Promise<ManagedPythonDaemonProcessInfo[]>;
|
||||
processAlive?: (pid: number) => boolean;
|
||||
killProcess?: ManagedPythonDaemonKillProcess;
|
||||
stopGraceMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
healthProbeMs?: number;
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const daemonStateSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
pid: z.number().int().positive(),
|
||||
|
|
@ -126,9 +170,9 @@ function defaultProcessAlive(pid: number): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function defaultKillProcess(pid: number): void {
|
||||
function defaultKillProcess(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
process.kill(pid, signal);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (code !== 'ESRCH') {
|
||||
|
|
@ -293,7 +337,7 @@ async function stopRecordedDaemon(input: {
|
|||
layout: ManagedPythonRuntimeLayout;
|
||||
state: ManagedPythonDaemonState;
|
||||
processAlive: (pid: number) => boolean;
|
||||
killProcess: (pid: number) => void;
|
||||
killProcess: ManagedPythonDaemonKillProcess;
|
||||
}): Promise<void> {
|
||||
if (input.processAlive(input.state.pid)) {
|
||||
input.killProcess(input.state.pid);
|
||||
|
|
@ -301,6 +345,323 @@ async function stopRecordedDaemon(input: {
|
|||
await removeState(input.layout);
|
||||
}
|
||||
|
||||
function runtimeRootForStopAll(options: ManagedPythonRuntimeLayoutOptions): string {
|
||||
return managedPythonRuntimeLayout(options).runtimeRoot;
|
||||
}
|
||||
|
||||
async function removeStatePaths(paths: string[]): Promise<void> {
|
||||
await Promise.all([...new Set(paths)].map((path) => rm(path, { force: true })));
|
||||
}
|
||||
|
||||
interface ManagedPythonDaemonStopCandidate {
|
||||
pid: number;
|
||||
source: ManagedPythonDaemonStopAllSource;
|
||||
host?: string;
|
||||
port?: number;
|
||||
version?: string;
|
||||
command?: string;
|
||||
statePaths: string[];
|
||||
}
|
||||
|
||||
function candidateUrl(candidate: ManagedPythonDaemonStopCandidate): string | undefined {
|
||||
if (!candidate.host || !candidate.port) {
|
||||
return undefined;
|
||||
}
|
||||
return `http://${candidate.host}:${candidate.port}`;
|
||||
}
|
||||
|
||||
function candidateEntry(candidate: ManagedPythonDaemonStopCandidate): ManagedPythonDaemonStopAllEntry {
|
||||
return {
|
||||
pid: candidate.pid,
|
||||
source: candidate.source,
|
||||
...(candidateUrl(candidate) ? { url: candidateUrl(candidate) } : {}),
|
||||
...(candidate.version ? { version: candidate.version } : {}),
|
||||
...(candidate.command ? { command: candidate.command } : {}),
|
||||
statePaths: [...candidate.statePaths],
|
||||
};
|
||||
}
|
||||
|
||||
async function probeCandidateHealth(
|
||||
candidate: ManagedPythonDaemonStopCandidate,
|
||||
timeoutMs: number,
|
||||
): Promise<'healthy' | 'unreachable' | undefined> {
|
||||
const url = candidateUrl(candidate);
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${url}/health`, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
return 'unreachable';
|
||||
}
|
||||
const body = (await response.json()) as unknown;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return 'unreachable';
|
||||
}
|
||||
return (body as Record<string, unknown>).status === 'healthy' ? 'healthy' : 'unreachable';
|
||||
} catch {
|
||||
return 'unreachable';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function readStateCandidates(runtimeRoot: string): Promise<ManagedPythonDaemonStopCandidate[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(runtimeRoot, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const candidates: ManagedPythonDaemonStopCandidate[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const statePath = join(runtimeRoot, entry.name, 'daemon.json');
|
||||
let state: ManagedPythonDaemonState | undefined;
|
||||
try {
|
||||
state = await readState(statePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!state) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
pid: state.pid,
|
||||
source: 'state',
|
||||
host: state.host,
|
||||
port: state.port,
|
||||
version: state.version,
|
||||
statePaths: [statePath],
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function tokenizeCommand(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
|
||||
tokens.push(match[1] ?? match[2] ?? match[3] ?? '');
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function executableName(token: string): string {
|
||||
return token.split(/[\\/]/).at(-1) ?? token;
|
||||
}
|
||||
|
||||
function isKtxDaemonExecutable(token: string): boolean {
|
||||
return executableName(token) === 'ktx-daemon' || executableName(token) === 'ktx-daemon.exe';
|
||||
}
|
||||
|
||||
function normalizedExecutableName(token: string): string {
|
||||
return executableName(token).replace(/\.exe$/i, '').toLowerCase();
|
||||
}
|
||||
|
||||
function hasUvRunPrefix(tokens: string[], daemonIndex: number): boolean {
|
||||
return normalizedExecutableName(tokens[0] ?? '') === 'uv' && tokens.slice(1, daemonIndex).includes('run');
|
||||
}
|
||||
|
||||
function isPythonExecutable(token: string): boolean {
|
||||
const name = normalizedExecutableName(token);
|
||||
return name === 'python' || name === 'python3';
|
||||
}
|
||||
|
||||
function hasPythonModulePrefix(tokens: string[], moduleFlagIndex: number): boolean {
|
||||
if (moduleFlagIndex === 1 && isPythonExecutable(tokens[0] ?? '')) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalizedExecutableName(tokens[0] ?? '') === 'uv' &&
|
||||
tokens.slice(1, moduleFlagIndex).includes('run') &&
|
||||
tokens.some((token, index) => index < moduleFlagIndex && isPythonExecutable(token))
|
||||
);
|
||||
}
|
||||
|
||||
function isKtxDaemonServeHttp(tokens: string[]): boolean {
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
if (
|
||||
isKtxDaemonExecutable(tokens[index] ?? '') &&
|
||||
tokens[index + 1] === 'serve-http' &&
|
||||
(index === 0 || hasUvRunPrefix(tokens, index))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
tokens[index] === '-m' &&
|
||||
tokens[index + 1] === 'ktx_daemon' &&
|
||||
tokens[index + 2] === 'serve-http' &&
|
||||
hasPythonModulePrefix(tokens, index)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseCommandOption(tokens: string[], option: string): string | undefined {
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (token === option) {
|
||||
return tokens[index + 1];
|
||||
}
|
||||
if (token?.startsWith(`${option}=`)) {
|
||||
return token.slice(option.length + 1);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function processCandidate(processInfo: ManagedPythonDaemonProcessInfo): ManagedPythonDaemonStopCandidate | undefined {
|
||||
const tokens = tokenizeCommand(processInfo.command);
|
||||
if (!isKtxDaemonServeHttp(tokens)) {
|
||||
return undefined;
|
||||
}
|
||||
const host = parseCommandOption(tokens, '--host') ?? '127.0.0.1';
|
||||
const rawPort = parseCommandOption(tokens, '--port');
|
||||
const parsedPort = rawPort ? Number.parseInt(rawPort, 10) : 8765;
|
||||
const port = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort : 8765;
|
||||
return {
|
||||
pid: processInfo.pid,
|
||||
source: 'process',
|
||||
host,
|
||||
port,
|
||||
command: processInfo.command,
|
||||
statePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeCandidates(candidates: ManagedPythonDaemonStopCandidate[]): ManagedPythonDaemonStopCandidate[] {
|
||||
const byPid = new Map<number, ManagedPythonDaemonStopCandidate>();
|
||||
for (const candidate of candidates) {
|
||||
const existing = byPid.get(candidate.pid);
|
||||
if (!existing) {
|
||||
byPid.set(candidate.pid, { ...candidate, statePaths: [...candidate.statePaths] });
|
||||
continue;
|
||||
}
|
||||
existing.statePaths.push(...candidate.statePaths);
|
||||
if (existing.source === 'process' && candidate.source === 'state') {
|
||||
byPid.set(candidate.pid, {
|
||||
...candidate,
|
||||
statePaths: [...new Set([...existing.statePaths, ...candidate.statePaths])],
|
||||
});
|
||||
} else {
|
||||
existing.statePaths = [...new Set(existing.statePaths)];
|
||||
}
|
||||
}
|
||||
return [...byPid.values()].sort((left, right) => left.pid - right.pid);
|
||||
}
|
||||
|
||||
function parsePosixProcessList(output: string): ManagedPythonDaemonProcessInfo[] {
|
||||
const processes: ManagedPythonDaemonProcessInfo[] = [];
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
processes.push({ pid: Number.parseInt(match[1], 10), command: match[2] });
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
function parseWindowsProcessList(output: string): ManagedPythonDaemonProcessInfo[] {
|
||||
if (!output.trim()) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(output) as unknown;
|
||||
const records = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const processes: ManagedPythonDaemonProcessInfo[] = [];
|
||||
for (const record of records) {
|
||||
if (!record || typeof record !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const value = record as Record<string, unknown>;
|
||||
const pid = value.ProcessId;
|
||||
const command = value.CommandLine;
|
||||
if (typeof pid === 'number' && typeof command === 'string' && command.length > 0) {
|
||||
processes.push({ pid, command });
|
||||
}
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
async function defaultListProcesses(platform: NodeJS.Platform = process.platform): Promise<ManagedPythonDaemonProcessInfo[]> {
|
||||
if (platform === 'win32') {
|
||||
const command = [
|
||||
'Get-CimInstance Win32_Process',
|
||||
'| Where-Object { $_.CommandLine -ne $null }',
|
||||
'| Select-Object ProcessId,CommandLine',
|
||||
'| ConvertTo-Json -Compress',
|
||||
].join(' ');
|
||||
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return parseWindowsProcessList(stdout);
|
||||
}
|
||||
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,command='], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return parsePosixProcessList(stdout);
|
||||
}
|
||||
|
||||
async function waitUntilStopped(input: {
|
||||
pid: number;
|
||||
processAlive: (pid: number) => boolean;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
do {
|
||||
if (!input.processAlive(input.pid)) {
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await delay(input.pollIntervalMs);
|
||||
} while (Date.now() <= deadline);
|
||||
return !input.processAlive(input.pid);
|
||||
}
|
||||
|
||||
async function discoverStopAllCandidates(
|
||||
options: ManagedPythonDaemonStopAllOptions,
|
||||
): Promise<{
|
||||
runtimeRoot: string;
|
||||
candidates: ManagedPythonDaemonStopCandidate[];
|
||||
scanErrors: string[];
|
||||
}> {
|
||||
const runtimeRoot = runtimeRootForStopAll(options);
|
||||
const stateCandidates = await readStateCandidates(runtimeRoot);
|
||||
const scanErrors: string[] = [];
|
||||
let processCandidates: ManagedPythonDaemonStopCandidate[] = [];
|
||||
try {
|
||||
const processes = await (options.listProcesses ?? defaultListProcesses)();
|
||||
processCandidates = processes.flatMap((processInfo) => {
|
||||
const candidate = processCandidate(processInfo);
|
||||
return candidate ? [candidate] : [];
|
||||
});
|
||||
} catch (error) {
|
||||
scanErrors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
return {
|
||||
runtimeRoot,
|
||||
candidates: mergeCandidates([...stateCandidates, ...processCandidates]),
|
||||
scanErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startManagedPythonDaemon(
|
||||
options: ManagedPythonDaemonStartOptions,
|
||||
): Promise<ManagedPythonDaemonStartResult> {
|
||||
|
|
@ -404,3 +765,63 @@ export async function stopManagedPythonDaemon(
|
|||
});
|
||||
return { status: 'stopped', layout, state };
|
||||
}
|
||||
|
||||
export async function stopAllManagedPythonDaemons(
|
||||
options: ManagedPythonDaemonStopAllOptions,
|
||||
): Promise<ManagedPythonDaemonStopAllResult> {
|
||||
const processAlive = options.processAlive ?? defaultProcessAlive;
|
||||
const killProcess = options.killProcess ?? defaultKillProcess;
|
||||
const stopGraceMs = options.stopGraceMs ?? 500;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 50;
|
||||
const healthProbeMs = options.healthProbeMs ?? 100;
|
||||
const discovery = await discoverStopAllCandidates(options);
|
||||
const stopped: ManagedPythonDaemonStopAllEntry[] = [];
|
||||
const stale: ManagedPythonDaemonStopAllEntry[] = [];
|
||||
const failed: ManagedPythonDaemonStopAllFailure[] = [];
|
||||
|
||||
for (const candidate of discovery.candidates) {
|
||||
const health = await probeCandidateHealth(candidate, healthProbeMs);
|
||||
const entry = { ...candidateEntry(candidate), ...(health ? { health } : {}) };
|
||||
if (!processAlive(candidate.pid)) {
|
||||
await removeStatePaths(candidate.statePaths);
|
||||
stale.push(entry);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
killProcess(candidate.pid, 'SIGTERM');
|
||||
if (
|
||||
!(await waitUntilStopped({
|
||||
pid: candidate.pid,
|
||||
processAlive,
|
||||
timeoutMs: stopGraceMs,
|
||||
pollIntervalMs,
|
||||
}))
|
||||
) {
|
||||
killProcess(candidate.pid, 'SIGKILL');
|
||||
if (
|
||||
!(await waitUntilStopped({
|
||||
pid: candidate.pid,
|
||||
processAlive,
|
||||
timeoutMs: stopGraceMs,
|
||||
pollIntervalMs,
|
||||
}))
|
||||
) {
|
||||
failed.push({ ...entry, detail: 'Process still running after SIGKILL' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await removeStatePaths(candidate.statePaths);
|
||||
stopped.push(entry);
|
||||
} catch (error) {
|
||||
failed.push({ ...entry, detail: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeRoot: discovery.runtimeRoot,
|
||||
stopped,
|
||||
stale,
|
||||
failed,
|
||||
scanErrors: discovery.scanErrors,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ManagedPythonDaemonStopAllResult,
|
||||
ManagedPythonDaemonStartResult,
|
||||
ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
|
|
@ -199,13 +200,63 @@ describe('runKtxRuntime', () => {
|
|||
})),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0' }, io.io, deps)).resolves.toBe(0);
|
||||
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: false }, io.io, deps)).resolves.toBe(0);
|
||||
|
||||
expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
|
||||
expect(io.stdout()).toContain('Stopped KTX Python daemon');
|
||||
expect(io.stdout()).toContain('pid: 4242');
|
||||
});
|
||||
|
||||
it('stops all discovered Python daemons and reports the summary', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
stopAllDaemons: vi.fn(async (): Promise<ManagedPythonDaemonStopAllResult> => ({
|
||||
runtimeRoot: '/runtime',
|
||||
stopped: [
|
||||
{ pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/runtime/0.2.0/daemon.json'] },
|
||||
{ pid: 5252, source: 'process', url: 'http://127.0.0.1:8765', statePaths: [] },
|
||||
],
|
||||
stale: [],
|
||||
failed: [],
|
||||
scanErrors: [],
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(0);
|
||||
|
||||
expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
|
||||
expect(io.stdout()).toContain('Stopped 2 KTX Python daemons');
|
||||
expect(io.stdout()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
|
||||
expect(io.stdout()).toContain('pid: 5252 source: process url: http://127.0.0.1:8765');
|
||||
});
|
||||
|
||||
it('returns failure when stop all cannot stop every daemon', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
stopAllDaemons: vi.fn(async (): Promise<ManagedPythonDaemonStopAllResult> => ({
|
||||
runtimeRoot: '/runtime',
|
||||
stopped: [],
|
||||
stale: [],
|
||||
failed: [
|
||||
{
|
||||
pid: 4242,
|
||||
source: 'state',
|
||||
url: 'http://127.0.0.1:61234',
|
||||
statePaths: ['/runtime/0.2.0/daemon.json'],
|
||||
detail: 'Process still running after SIGKILL',
|
||||
},
|
||||
],
|
||||
scanErrors: ['ps failed'],
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('Stopped 0 KTX Python daemons; failed 1');
|
||||
expect(io.stderr()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
|
||||
expect(io.stderr()).toContain('process scan: ps failed');
|
||||
});
|
||||
|
||||
it('prints runtime status as JSON', async () => {
|
||||
const io = makeIo();
|
||||
const deps: KtxRuntimeDeps = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
stopAllManagedPythonDaemons,
|
||||
startManagedPythonDaemon,
|
||||
stopManagedPythonDaemon,
|
||||
type ManagedPythonDaemonStopAllResult,
|
||||
type ManagedPythonDaemonStartResult,
|
||||
type ManagedPythonDaemonStopResult,
|
||||
} from './managed-python-daemon.js';
|
||||
|
|
@ -22,7 +24,7 @@ import {
|
|||
export type KtxRuntimeArgs =
|
||||
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
|
||||
| { command: 'stop'; cliVersion: string }
|
||||
| { command: 'stop'; cliVersion: string; all: boolean }
|
||||
| { command: 'status'; cliVersion: string; json: boolean }
|
||||
| { command: 'doctor'; cliVersion: string; json: boolean }
|
||||
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
|
||||
|
|
@ -35,6 +37,7 @@ export interface KtxRuntimeDeps {
|
|||
force?: boolean;
|
||||
}) => Promise<ManagedPythonDaemonStartResult>;
|
||||
stopDaemon?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopResult>;
|
||||
stopAllDaemons?: (options: { cliVersion: string }) => Promise<ManagedPythonDaemonStopAllResult>;
|
||||
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeStatus>;
|
||||
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise<ManagedPythonRuntimeDoctorCheck[]>;
|
||||
pruneRuntime?: (options: {
|
||||
|
|
@ -81,6 +84,58 @@ function writeDaemonStop(io: KtxCliIo, result: ManagedPythonDaemonStopResult): v
|
|||
io.stdout.write(`state: ${result.layout.daemonStatePath}\n`);
|
||||
}
|
||||
|
||||
function writeStopAllEntry(io: KtxCliIo, entry: { pid: number; source: string; url?: string; health?: string; detail?: string }): void {
|
||||
io.stdout.write(
|
||||
`pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${
|
||||
entry.health ? ` health: ${entry.health}` : ''
|
||||
}${
|
||||
entry.detail ? ` detail: ${entry.detail}` : ''
|
||||
}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeDaemonStopAll(io: KtxCliIo, result: ManagedPythonDaemonStopAllResult): number {
|
||||
const failed = result.failed.length + result.scanErrors.length;
|
||||
if (
|
||||
result.stopped.length === 0 &&
|
||||
result.stale.length === 0 &&
|
||||
result.failed.length === 0 &&
|
||||
result.scanErrors.length === 0
|
||||
) {
|
||||
io.stdout.write('No KTX Python daemons found\n');
|
||||
return 0;
|
||||
}
|
||||
if (failed === 0) {
|
||||
io.stdout.write(`Stopped ${result.stopped.length} KTX Python daemons\n`);
|
||||
if (result.stale.length > 0) {
|
||||
io.stdout.write(`Cleaned ${result.stale.length} stale daemon states\n`);
|
||||
}
|
||||
for (const entry of result.stopped) {
|
||||
writeStopAllEntry(io, entry);
|
||||
}
|
||||
for (const entry of result.stale) {
|
||||
writeStopAllEntry(io, entry);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
io.stderr.write(
|
||||
`Stopped ${result.stopped.length} KTX Python daemons; failed ${result.failed.length}${
|
||||
result.stale.length > 0 ? `; cleaned stale ${result.stale.length}` : ''
|
||||
}\n`,
|
||||
);
|
||||
for (const entry of result.failed) {
|
||||
io.stderr.write(
|
||||
`pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${
|
||||
entry.health ? ` health: ${entry.health}` : ''
|
||||
} detail: ${entry.detail}\n`,
|
||||
);
|
||||
}
|
||||
for (const error of result.scanErrors) {
|
||||
io.stderr.write(`process scan: ${error}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
|
||||
io.stdout.write('KTX Python runtime\n');
|
||||
io.stdout.write(`status: ${status.kind}\n`);
|
||||
|
|
@ -142,10 +197,16 @@ export async function runKtxRuntime(
|
|||
return 0;
|
||||
}
|
||||
if (args.command === 'stop') {
|
||||
const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
|
||||
const result = await stopDaemon({ cliVersion: args.cliVersion });
|
||||
writeDaemonStop(io, result);
|
||||
return 0;
|
||||
if (args.all) {
|
||||
const stopAllDaemons = deps.stopAllDaemons ?? stopAllManagedPythonDaemons;
|
||||
const result = await stopAllDaemons({ cliVersion: args.cliVersion });
|
||||
return writeDaemonStopAll(io, result);
|
||||
} else {
|
||||
const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
|
||||
const result = await stopDaemon({ cliVersion: args.cliVersion });
|
||||
writeDaemonStop(io, result);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (args.command === 'status') {
|
||||
const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { cancel, isCancel, select } from '@clack/prompts';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
|
||||
import { ktxLocalStateDbPath, loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import { formatSetupNextStepLines } from './next-steps.js';
|
||||
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
|
||||
|
|
@ -248,6 +249,31 @@ function sourceConnections(config: Awaited<ReturnType<typeof loadKtxProject>>['c
|
|||
.sort((left, right) => left.connectionId.localeCompare(right.connectionId));
|
||||
}
|
||||
|
||||
type LocalIngestStatusReport = NonNullable<Awaited<ReturnType<typeof getLatestLocalIngestStatus>>>;
|
||||
|
||||
function reportHasSavedContext(report: LocalIngestStatusReport): boolean {
|
||||
if (report.body.failedWorkUnits.length > 0) {
|
||||
return false;
|
||||
}
|
||||
const counts = savedMemoryCountsForReport(report);
|
||||
return counts.wikiCount > 0 || counts.slCount > 0;
|
||||
}
|
||||
|
||||
async function readIngestContextStatus(project: KtxLocalProject): Promise<KtxSetupContextStatusSummary | null> {
|
||||
if (!existsSync(ktxLocalStateDbPath(project))) {
|
||||
return null;
|
||||
}
|
||||
const report = await getLatestLocalIngestStatus(project);
|
||||
if (!report || !reportHasSavedContext(report)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
status: 'completed',
|
||||
runId: report.runId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupStatus> {
|
||||
const resolvedProjectDir = resolve(projectDir);
|
||||
if (!existsSync(join(resolvedProjectDir, 'ktx.yaml'))) {
|
||||
|
|
@ -279,6 +305,10 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
|
|||
|
||||
const completedSteps = project.config.setup?.completed_steps ?? [];
|
||||
const contextState = await readKtxSetupContextState(resolvedProjectDir);
|
||||
const setupContextStatus = setupContextStatusFromState(contextState, {
|
||||
completedStep: completedSteps.includes('context'),
|
||||
});
|
||||
const ingestContextStatus = setupContextStatus.ready ? null : await readIngestContextStatus(project);
|
||||
const databaseIds = project.config.setup?.database_connection_ids ?? Object.keys(project.config.connections);
|
||||
const databasesComplete = completedSteps.includes('databases');
|
||||
const manifest = await readKtxAgentInstallManifest(resolvedProjectDir);
|
||||
|
|
@ -301,7 +331,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
|
|||
...source,
|
||||
ready: completedSteps.includes('sources'),
|
||||
})),
|
||||
context: setupContextStatusFromState(contextState, { completedStep: completedSteps.includes('context') }),
|
||||
context: ingestContextStatus ?? setupContextStatus,
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,9 +368,9 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
const knowledgeSearch = structuredContent<{
|
||||
results: Array<{ key: string; summary: string; score: number }>;
|
||||
totalFound: number;
|
||||
}>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract', limit: 5 } }));
|
||||
}>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract-first definition', limit: 10 } }));
|
||||
expect(knowledgeSearch.totalFound).toBeGreaterThan(0);
|
||||
expect(knowledgeSearch.results.map((result) => result.key)).toContain('arr-contract-first');
|
||||
expect(knowledgeSearch.results.map((result) => result.key)).toContain('orbit-arr-contract-first-definition');
|
||||
|
||||
const knowledgeRead = structuredContent<{
|
||||
key: string;
|
||||
|
|
@ -378,26 +378,26 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
content: string;
|
||||
tags: string[];
|
||||
slRefs: string[];
|
||||
}>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'arr-contract-first' } }));
|
||||
expect(knowledgeRead.key).toBe('arr-contract-first');
|
||||
}>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'orbit-arr-contract-first-definition' } }));
|
||||
expect(knowledgeRead.key).toBe('orbit-arr-contract-first-definition');
|
||||
expect(knowledgeRead.summary).toContain('ARR');
|
||||
expect(knowledgeRead.content).toContain('contract');
|
||||
expect(knowledgeRead.slRefs).toContain('orbit_demo.contracts');
|
||||
expect(knowledgeRead.slRefs).toContain('mart_arr_daily');
|
||||
|
||||
const slRead = structuredContent<{ sourceName: string; yaml: string }>(
|
||||
await client.callTool({
|
||||
name: 'sl_read_source',
|
||||
arguments: { connectionId: 'orbit_demo', sourceName: 'accounts' },
|
||||
arguments: { connectionId: 'dbt-main', sourceName: 'mart_arr_daily' },
|
||||
}),
|
||||
);
|
||||
expect(slRead.sourceName).toBe('accounts');
|
||||
expect(slRead.yaml).toContain('name: accounts');
|
||||
expect(slRead.sourceName).toBe('mart_arr_daily');
|
||||
expect(slRead.yaml).toContain('name: mart_arr_daily');
|
||||
expect(slRead.yaml).toContain('measures:');
|
||||
|
||||
const slValidate = structuredContent<{ success: boolean; errors: string[]; warnings: string[] }>(
|
||||
await client.callTool({
|
||||
name: 'sl_validate',
|
||||
arguments: { connectionId: 'orbit_demo', names: ['accounts', 'contracts'] },
|
||||
arguments: { connectionId: 'dbt-main', names: ['mart_arr_daily', 'stg_contracts'] },
|
||||
}),
|
||||
);
|
||||
expect(slValidate.success).toBe(true);
|
||||
|
|
@ -716,7 +716,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
'--project-dir',
|
||||
projectDir,
|
||||
'--token-env',
|
||||
'NOTION_AUTH_TOKEN',
|
||||
'NOTION_TOKEN',
|
||||
'--crawl-mode',
|
||||
'all_accessible',
|
||||
'--max-pages',
|
||||
|
|
@ -729,7 +729,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
|
||||
const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(yaml).toContain('driver: notion');
|
||||
expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN');
|
||||
expect(yaml).toContain('auth_token_ref: env:NOTION_TOKEN');
|
||||
expect(yaml).toContain('crawl_mode: all_accessible');
|
||||
expect(yaml).toContain('max_pages_per_run: 5');
|
||||
expect(yaml).not.toContain('ntn_');
|
||||
|
|
@ -737,7 +737,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
const parsed = parseKtxProjectConfig(yaml);
|
||||
expect(parsed.connections['notion-main']).toMatchObject({
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_AUTH_TOKEN',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'all_accessible',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ interface BuiltMocks {
|
|||
agentRunner: any;
|
||||
slValidator: any;
|
||||
toolsetFactory: any;
|
||||
logger: any;
|
||||
}
|
||||
|
||||
const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
|
||||
|
|
@ -131,6 +132,7 @@ const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
|
|||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
},
|
||||
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
};
|
||||
|
||||
return { ...defaults, ...overrides };
|
||||
|
|
@ -179,6 +181,7 @@ const buildService = (mocks: BuiltMocks): MemoryAgentService =>
|
|||
telemetry: {
|
||||
trackMemoryIngestion: mocks.eventTracker.trackEvent,
|
||||
},
|
||||
logger: mocks.logger,
|
||||
});
|
||||
|
||||
const baseInput = {
|
||||
|
|
@ -238,6 +241,27 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => {
|
|||
expect(result.commitHash).toBe('cafebabe');
|
||||
});
|
||||
|
||||
it('logs prompt debug output when KTX_MEMORY_AGENT_DEBUG_PROMPTS is enabled', async () => {
|
||||
const previousDebugPrompts = process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
|
||||
const mocks = buildMocks();
|
||||
const svc = buildService(mocks);
|
||||
|
||||
try {
|
||||
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = '1';
|
||||
|
||||
await svc.ingest(baseInput);
|
||||
|
||||
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] system='));
|
||||
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] user='));
|
||||
} finally {
|
||||
if (previousDebugPrompts === undefined) {
|
||||
delete process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
|
||||
} else {
|
||||
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = previousDebugPrompts;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('empty path: squash returns no touched paths → no enqueue, cleanup(empty), commitHash=null', async () => {
|
||||
const mocks = buildMocks();
|
||||
mocks.gitService.squashMergeIntoMain.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export async function runKtxLlmHealthCheck(
|
|||
): Promise<KtxLlmHealthCheckResult> {
|
||||
try {
|
||||
const { generateText: runGenerateTextOverride, ...providerDeps } = options.deps ?? {};
|
||||
const provider = createKtxLlmProvider(config, providerDeps);
|
||||
const provider = createKtxLlmProvider(config, { ...providerDeps, devtoolsEnabled: false });
|
||||
const runGenerateText = runGenerateTextOverride ?? generateText;
|
||||
await withTimeout(
|
||||
runGenerateText({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,138 @@
|
|||
import type { LanguageModel } from 'ai';
|
||||
import { devToolsMiddleware as defaultDevToolsMiddleware } from '@ai-sdk/devtools';
|
||||
import { wrapLanguageModel as defaultWrapLanguageModel, type LanguageModel } from 'ai';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createKtxLlmProvider } from './model-provider.js';
|
||||
import { createKtxLlmProvider, type KtxLlmProviderFactoryDeps } from './model-provider.js';
|
||||
|
||||
const languageModel = (modelId: string, provider = 'test'): LanguageModel => ({ modelId, provider }) as LanguageModel;
|
||||
const devtoolsMiddleware = (): ReturnType<typeof defaultDevToolsMiddleware> => ({ specificationVersion: 'v3' });
|
||||
const wrapWith = (model: LanguageModel) =>
|
||||
vi.fn((_options: Parameters<typeof defaultWrapLanguageModel>[0]) => model as ReturnType<typeof defaultWrapLanguageModel>);
|
||||
|
||||
describe('createKtxLlmProvider', () => {
|
||||
it('wraps language models with DevTools middleware when explicitly enabled', () => {
|
||||
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
|
||||
const wrappedModel = languageModel('claude-sonnet-4-6', 'anthropic-devtools');
|
||||
const middleware = devtoolsMiddleware();
|
||||
const wrapLanguageModel = wrapWith(wrappedModel);
|
||||
const devToolsMiddleware = vi.fn(devtoolsMiddleware);
|
||||
|
||||
const provider = createKtxLlmProvider(
|
||||
{
|
||||
backend: 'anthropic',
|
||||
anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
|
||||
modelSlots: { default: 'claude-sonnet-4-6' },
|
||||
promptCaching: { enabled: false },
|
||||
},
|
||||
{
|
||||
createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
|
||||
devtoolsEnabled: true,
|
||||
wrapLanguageModel,
|
||||
devToolsMiddleware,
|
||||
} satisfies KtxLlmProviderFactoryDeps,
|
||||
);
|
||||
|
||||
expect(provider.getModel('default')).toBe(wrappedModel);
|
||||
expect(devToolsMiddleware).toHaveBeenCalledTimes(1);
|
||||
expect(wrapLanguageModel).toHaveBeenCalledWith({
|
||||
model: anthropicModel,
|
||||
middleware,
|
||||
modelId: 'claude-sonnet-4-6',
|
||||
providerId: 'anthropic',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not wrap language models by default', () => {
|
||||
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
|
||||
const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
|
||||
const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware);
|
||||
|
||||
const provider = createKtxLlmProvider(
|
||||
{
|
||||
backend: 'anthropic',
|
||||
anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
|
||||
modelSlots: { default: 'claude-sonnet-4-6' },
|
||||
promptCaching: { enabled: false },
|
||||
},
|
||||
{
|
||||
createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
|
||||
wrapLanguageModel,
|
||||
devToolsMiddleware,
|
||||
} satisfies KtxLlmProviderFactoryDeps,
|
||||
);
|
||||
|
||||
expect(provider.getModel('default')).toBe(anthropicModel);
|
||||
expect(wrapLanguageModel).not.toHaveBeenCalled();
|
||||
expect(devToolsMiddleware).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps language models when KTX_AI_DEVTOOLS_ENABLED is true', () => {
|
||||
const originalEnv = process.env.KTX_AI_DEVTOOLS_ENABLED;
|
||||
process.env.KTX_AI_DEVTOOLS_ENABLED = 'true';
|
||||
try {
|
||||
const gatewayModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway');
|
||||
const wrappedModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway-devtools');
|
||||
const wrapLanguageModel = wrapWith(wrappedModel);
|
||||
|
||||
const provider = createKtxLlmProvider(
|
||||
{
|
||||
backend: 'gateway',
|
||||
gateway: { baseURL: 'https://gateway.test/v1' },
|
||||
modelSlots: { default: 'anthropic/claude-sonnet-4-6' },
|
||||
promptCaching: { enabled: false },
|
||||
},
|
||||
{
|
||||
createGateway: vi.fn(() => vi.fn(() => gatewayModel)),
|
||||
wrapLanguageModel,
|
||||
devToolsMiddleware: vi.fn(devtoolsMiddleware),
|
||||
} satisfies KtxLlmProviderFactoryDeps,
|
||||
);
|
||||
|
||||
expect(provider.getModel('default')).toBe(wrappedModel);
|
||||
expect(wrapLanguageModel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.KTX_AI_DEVTOOLS_ENABLED;
|
||||
} else {
|
||||
process.env.KTX_AI_DEVTOOLS_ENABLED = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not wrap language models in production even when enabled', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
try {
|
||||
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
|
||||
const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
|
||||
const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware);
|
||||
|
||||
const provider = createKtxLlmProvider(
|
||||
{
|
||||
backend: 'anthropic',
|
||||
anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
|
||||
modelSlots: { default: 'claude-sonnet-4-6' },
|
||||
promptCaching: { enabled: false },
|
||||
},
|
||||
{
|
||||
createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
|
||||
devtoolsEnabled: true,
|
||||
wrapLanguageModel,
|
||||
devToolsMiddleware,
|
||||
} satisfies KtxLlmProviderFactoryDeps,
|
||||
);
|
||||
|
||||
expect(provider.getModel('default')).toBe(anthropicModel);
|
||||
expect(wrapLanguageModel).not.toHaveBeenCalled();
|
||||
expect(devToolsMiddleware).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses direct Anthropic with both beta headers', () => {
|
||||
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
|
||||
const anthropic = vi.fn(() => anthropicModel);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { devToolsMiddleware } from '@ai-sdk/devtools';
|
||||
import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
|
||||
import { createGateway, generateText, type LanguageModel } from 'ai';
|
||||
import { createGateway, generateText, wrapLanguageModel, type LanguageModel } from 'ai';
|
||||
import { createKtxToolCallRepairHandler } from './repair.js';
|
||||
import type {
|
||||
KtxLlmConfig,
|
||||
|
|
@ -21,6 +22,9 @@ export interface KtxLlmProviderFactoryDeps {
|
|||
createVertexAnthropic?: VertexAnthropicFactory;
|
||||
createGateway?: GatewayFactory;
|
||||
generateText?: typeof generateText;
|
||||
devtoolsEnabled?: boolean;
|
||||
wrapLanguageModel?: typeof wrapLanguageModel;
|
||||
devToolsMiddleware?: typeof devToolsMiddleware;
|
||||
}
|
||||
|
||||
const DEFAULT_PROMPT_CACHING: KtxPromptCachingConfig = {
|
||||
|
|
@ -40,10 +44,27 @@ function resolvePromptCaching(config: KtxLlmConfig): KtxPromptCachingConfig {
|
|||
return { ...DEFAULT_PROMPT_CACHING, ...config.promptCaching };
|
||||
}
|
||||
|
||||
function resolveDevtoolsEnabled(override: boolean | undefined): boolean {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return false;
|
||||
}
|
||||
if (override !== undefined) {
|
||||
return override;
|
||||
}
|
||||
const value = process.env.KTX_AI_DEVTOOLS_ENABLED?.trim().toLowerCase();
|
||||
return value === 'true' || value === '1' || value === 'yes';
|
||||
}
|
||||
|
||||
export function modelIdFromLanguageModel(model: LanguageModel | string): string {
|
||||
return typeof model === 'string' ? model : ((model as { modelId?: string }).modelId ?? '');
|
||||
}
|
||||
|
||||
function providerIdFromLanguageModel(model: Exclude<LanguageModel, string>): string | undefined {
|
||||
return typeof (model as { provider?: unknown }).provider === 'string'
|
||||
? (model as { provider: string }).provider
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isAnthropicProtocolModel(model: LanguageModel | string): boolean {
|
||||
const modelId = modelIdFromLanguageModel(model);
|
||||
return modelId.startsWith('claude-') || modelId.startsWith('anthropic/') || modelId.includes('/claude-');
|
||||
|
|
@ -53,6 +74,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
|
|||
private readonly promptCaching: KtxPromptCachingConfig;
|
||||
private readonly getModelByResolvedName: (modelId: string) => LanguageModel;
|
||||
private readonly runGenerateText: typeof generateText;
|
||||
private readonly devtoolsEnabled: boolean;
|
||||
private readonly runWrapLanguageModel: typeof wrapLanguageModel;
|
||||
private readonly createDevToolsMiddleware: typeof devToolsMiddleware;
|
||||
|
||||
constructor(
|
||||
private readonly config: KtxLlmConfig,
|
||||
|
|
@ -60,6 +84,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
|
|||
) {
|
||||
this.promptCaching = resolvePromptCaching(config);
|
||||
this.runGenerateText = deps.generateText ?? generateText;
|
||||
this.devtoolsEnabled = resolveDevtoolsEnabled(deps.devtoolsEnabled);
|
||||
this.runWrapLanguageModel = deps.wrapLanguageModel ?? wrapLanguageModel;
|
||||
this.createDevToolsMiddleware = deps.devToolsMiddleware ?? devToolsMiddleware;
|
||||
this.getModelByResolvedName = this.createModelFactory(config, deps);
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +95,7 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
|
|||
}
|
||||
|
||||
getModelByName(modelId: string): LanguageModel {
|
||||
return this.getModelByResolvedName(modelId);
|
||||
return this.withDevtools(this.getModelByResolvedName(modelId));
|
||||
}
|
||||
|
||||
cacheMarker(ttl: KtxPromptCacheTtl, model?: LanguageModel | string) {
|
||||
|
|
@ -113,6 +140,18 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
|
|||
return this.config.modelSlots[role] ?? this.config.modelSlots.default;
|
||||
}
|
||||
|
||||
private withDevtools(model: LanguageModel): LanguageModel {
|
||||
if (!this.devtoolsEnabled || typeof model === 'string') {
|
||||
return model;
|
||||
}
|
||||
return this.runWrapLanguageModel({
|
||||
model: model as Parameters<typeof wrapLanguageModel>[0]['model'],
|
||||
middleware: this.createDevToolsMiddleware(),
|
||||
modelId: modelIdFromLanguageModel(model),
|
||||
providerId: providerIdFromLanguageModel(model),
|
||||
});
|
||||
}
|
||||
|
||||
private createModelFactory(config: KtxLlmConfig, deps: KtxLlmProviderFactoryDeps): (modelId: string) => LanguageModel {
|
||||
if (config.backend === 'anthropic') {
|
||||
const anthropic = (deps.createAnthropic ?? createAnthropic)({
|
||||
|
|
|
|||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -135,72 +135,86 @@ describe('standalone example docs', () => {
|
|||
assert.doesNotMatch(readme, /--historic-sql-min-calls/);
|
||||
});
|
||||
|
||||
it('lists every published TypeScript package in the package root README', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
it('lists every workspace package in the contributor docs', async () => {
|
||||
const contributing = await readText('docs-site/content/docs/community/contributing.mdx');
|
||||
|
||||
assert.match(rootReadme, /`packages\/context`/);
|
||||
assert.match(rootReadme, /`packages\/cli`/);
|
||||
assert.match(rootReadme, /`packages\/connector-bigquery`/);
|
||||
assert.match(rootReadme, /`packages\/connector-clickhouse`/);
|
||||
assert.match(rootReadme, /`packages\/connector-mysql`/);
|
||||
assert.match(rootReadme, /`packages\/connector-postgres`/);
|
||||
assert.match(rootReadme, /`packages\/connector-snowflake`/);
|
||||
assert.match(rootReadme, /`packages\/connector-sqlite`/);
|
||||
assert.match(rootReadme, /`packages\/connector-sqlserver`/);
|
||||
assert.match(rootReadme, /`python\/ktx-sl`/);
|
||||
assert.match(rootReadme, /`python\/ktx-daemon`/);
|
||||
assert.match(contributing, /cli\/\s+# CLI entry point/);
|
||||
assert.match(contributing, /context\/\s+# Core context engine/);
|
||||
assert.match(contributing, /llm\/\s+# LLM client abstraction/);
|
||||
assert.match(contributing, /connector-bigquery\/\s+# BigQuery connector/);
|
||||
assert.match(contributing, /connector-clickhouse\/\s+# ClickHouse connector/);
|
||||
assert.match(contributing, /connector-mysql\/\s+# MySQL connector/);
|
||||
assert.match(contributing, /connector-postgres\/\s+# PostgreSQL connector/);
|
||||
assert.match(contributing, /connector-snowflake\/\s+# Snowflake connector/);
|
||||
assert.match(contributing, /connector-sqlite\/\s+# SQLite connector/);
|
||||
assert.match(contributing, /connector-sqlserver\/\s+# SQL Server connector/);
|
||||
assert.match(contributing, /ktx-sl\/\s+# Semantic layer/);
|
||||
assert.match(contributing, /ktx-daemon\/\s+# Daemon/);
|
||||
});
|
||||
|
||||
it('documents every standalone MCP tool that the CLI server exposes', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
const servingAgents = await readText('docs-site/content/docs/guides/serving-agents.mdx');
|
||||
|
||||
assert.match(rootReadme, /`connection_list`/);
|
||||
assert.match(rootReadme, /`knowledge_search`/);
|
||||
assert.match(rootReadme, /`knowledge_read`/);
|
||||
assert.match(rootReadme, /`knowledge_write`/);
|
||||
assert.match(rootReadme, /`sl_list_sources`/);
|
||||
assert.match(rootReadme, /`sl_read_source`/);
|
||||
assert.match(rootReadme, /`sl_write_source`/);
|
||||
assert.match(rootReadme, /`sl_validate`/);
|
||||
assert.match(rootReadme, /`sl_query`/);
|
||||
assert.match(rootReadme, /`ingest_trigger`/);
|
||||
assert.match(rootReadme, /`ingest_status`/);
|
||||
assert.match(rootReadme, /`ingest_report`/);
|
||||
assert.match(rootReadme, /`ingest_replay`/);
|
||||
for (const tool of [
|
||||
'connection_list',
|
||||
'connection_test',
|
||||
'knowledge_search',
|
||||
'knowledge_read',
|
||||
'knowledge_write',
|
||||
'sl_list_sources',
|
||||
'sl_read_source',
|
||||
'sl_write_source',
|
||||
'sl_validate',
|
||||
'sl_query',
|
||||
'scan_trigger',
|
||||
'scan_status',
|
||||
'scan_report',
|
||||
'scan_list_artifacts',
|
||||
'scan_read_artifact',
|
||||
'ingest_trigger',
|
||||
'ingest_status',
|
||||
'ingest_report',
|
||||
'ingest_replay',
|
||||
'memory_capture',
|
||||
'memory_capture_status',
|
||||
]) {
|
||||
assert.match(servingAgents, new RegExp(`\`${tool}\``));
|
||||
}
|
||||
});
|
||||
|
||||
it('walks through ktx connection list and ktx connection test in the README quickstart', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
it('walks through connection testing in the quickstart and CLI reference', async () => {
|
||||
const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx');
|
||||
const connectionReference = await readText('docs-site/content/docs/cli-reference/ktx-connection.mdx');
|
||||
|
||||
assert.match(rootReadme, /connection list --project-dir/);
|
||||
assert.match(rootReadme, /connection test warehouse --project-dir/);
|
||||
assert.match(rootReadme, /Driver: sqlite/);
|
||||
assert.match(rootReadme, /Tables: 1/);
|
||||
assert.match(connectionReference, /ktx connection list/);
|
||||
assert.match(connectionReference, /ktx connection test my-warehouse/);
|
||||
assert.match(quickstart, /Connection test passed/);
|
||||
assert.match(quickstart, /Driver: PostgreSQL .* Tables: 42/);
|
||||
});
|
||||
|
||||
it('documents public npm and managed runtime usage in the README', async () => {
|
||||
it('documents public npm and managed runtime usage', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx');
|
||||
const packageArtifacts = await readText('examples/package-artifacts/README.md');
|
||||
|
||||
assert.match(rootReadme, publicPackagePattern('npx {package} setup demo --no-input'));
|
||||
assert.match(rootReadme, publicPackagePattern('npx {package} sl query'));
|
||||
assert.match(rootReadme, publicPackagePattern('npm install {package}'));
|
||||
assert.match(rootReadme, publicPackagePattern('npm install -g {package}'));
|
||||
assert.match(rootReadme, /ktx runtime install/);
|
||||
assert.match(rootReadme, /ktx runtime status/);
|
||||
assert.match(rootReadme, /ktx runtime doctor/);
|
||||
assert.match(rootReadme, /ktx runtime start/);
|
||||
assert.match(rootReadme, /ktx runtime stop/);
|
||||
assert.match(rootReadme, /ktx runtime prune --dry-run/);
|
||||
assert.match(rootReadme, /ktx runtime prune --yes/);
|
||||
assert.match(rootReadme, /KTX requires `uv` on `PATH`/);
|
||||
assert.match(rootReadme, /KTX doesn't download `uv` automatically/);
|
||||
assert.match(quickstart, publicPackagePattern('npm install -g {package}'));
|
||||
assert.match(quickstart, /ktx runtime install --feature local-embeddings --yes/);
|
||||
assert.match(quickstart, /ktx runtime start --feature local-embeddings/);
|
||||
assert.match(quickstart, /Install `uv`, run `ktx runtime doctor`/);
|
||||
assert.match(packageArtifacts, /requires `uv` on `PATH`/);
|
||||
assert.match(packageArtifacts, /ktx runtime status/);
|
||||
assert.match(packageArtifacts, /ktx runtime doctor/);
|
||||
assert.match(packageArtifacts, /ktx runtime prune --dry-run/);
|
||||
assert.match(packageArtifacts, /ktx runtime prune --yes/);
|
||||
assert.match(
|
||||
rootReadme,
|
||||
runtimeWheelPackagePattern(
|
||||
'release\\s+artifact manifest contains the public npm tarball and the\\s+bundled `{package}`\\s+runtime wheel',
|
||||
packageArtifacts,
|
||||
new RegExp(
|
||||
`artifact manifest contains the public \`${escapeRegExp(publicNpmPackageName())}\` npm tarball and the\\s+bundled \`${escapeRegExp(
|
||||
runtimeWheelPackageName(),
|
||||
)}\` runtime wheel`,
|
||||
),
|
||||
);
|
||||
assert.match(rootReadme, /source packages for\s+development, not public release artifacts/);
|
||||
assert.match(rootReadme, /ktx serve --mcp stdio/);
|
||||
assert.doesNotMatch(rootReadme, /uv run ktx-daemon serve-http/);
|
||||
assert.doesNotMatch(rootReadme, /--semantic-compute-url http:\/\/127\.0\.0\.1:8765/);
|
||||
|
|
@ -232,14 +246,17 @@ describe('standalone example docs', () => {
|
|||
assert.doesNotMatch(readme, /python -m ktx_daemon semantic-validate/);
|
||||
});
|
||||
|
||||
it('replaces the fake-ingest smoke with a ktx scan walkthrough in the README', async () => {
|
||||
it('documents scan workflows in the docs site', async () => {
|
||||
const rootReadme = await readText('README.md');
|
||||
const buildingContext = await readText('docs-site/content/docs/guides/building-context.mdx');
|
||||
const scanReference = await readText('docs-site/content/docs/cli-reference/ktx-scan.mdx');
|
||||
|
||||
assert.match(rootReadme, /### Scan the demo warehouse/);
|
||||
assert.match(rootReadme, /scan warehouse --project-dir/);
|
||||
assert.match(rootReadme, /scan status --project-dir/);
|
||||
assert.match(rootReadme, /scan report --project-dir/);
|
||||
assert.match(rootReadme, /raw-sources\/warehouse\/live-database/);
|
||||
assert.match(buildingContext, /ktx dev scan <connection-id>/);
|
||||
assert.match(buildingContext, /ktx dev scan status <run-id>/);
|
||||
assert.match(buildingContext, /ktx dev scan report <run-id>/);
|
||||
assert.match(scanReference, /ktx dev scan <connectionId> \[options\]/);
|
||||
assert.match(rootReadme, /raw-sources\//);
|
||||
assert.match(rootReadme, /live-database\//);
|
||||
assert.doesNotMatch(rootReadme, /Run a local ingest smoke test/);
|
||||
assert.doesNotMatch(rootReadme, /ktx dev ingest run --project-dir/);
|
||||
assert.doesNotMatch(rootReadme, /ktx ingest status --project-dir/);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue