mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
Initial open-source release
This commit is contained in:
commit
1a42152e6f
1199 changed files with 257054 additions and 0 deletions
BIN
packages/cli/assets/demo/orbit/demo.db
Normal file
BIN
packages/cli/assets/demo/orbit/demo.db
Normal file
Binary file not shown.
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
summary: Account activation policy changed on January 15, 2026.
|
||||
tags:
|
||||
- growth
|
||||
- activation
|
||||
- policy
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.accounts
|
||||
- orbit_demo.purchase_requests
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Before January 15, 2026, activation meant first requester login.
|
||||
|
||||
On and after January 15, 2026, activation requires an approved purchase request and at least three activated requesters.
|
||||
|
||||
Always separate pre-policy and post-policy cohorts when comparing activation rates.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
summary: ARR uses contract-first precedence before subscription-derived revenue.
|
||||
tags:
|
||||
- finance
|
||||
- arr
|
||||
- revenue
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.contracts
|
||||
- orbit_demo.arr_movements
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
ARR is calculated from active recurring contract ARR before falling back to subscription-derived revenue.
|
||||
|
||||
Do not double-count subscription MRR when an active contract row covers the same account and period.
|
||||
|
||||
Exclude cancelled contracts ending before the metric date, future-starting contracts, internal accounts, and test accounts.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
summary: Customer health combines support severity and procurement activity.
|
||||
tags:
|
||||
- customer-success
|
||||
- health
|
||||
- churn-risk
|
||||
refs:
|
||||
- nrr-retention
|
||||
sl_refs:
|
||||
- orbit_demo.support_tickets
|
||||
- orbit_demo.purchase_requests
|
||||
- orbit_demo.accounts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
High-risk accounts have multiple recent high-severity tickets or no recent procurement activity on growth and enterprise plans.
|
||||
|
||||
Medium risk captures partial support pressure or a material month-over-month decline in procurement activity.
|
||||
|
||||
Internal and test accounts are excluded from customer health scoring.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
summary: Discount expirations are tracked separately from organic contraction.
|
||||
tags:
|
||||
- finance
|
||||
- retention
|
||||
refs:
|
||||
- arr-contract-first
|
||||
- nrr-retention
|
||||
sl_refs:
|
||||
- orbit_demo.contracts
|
||||
- orbit_demo.arr_movements
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Discount expiration events identify pricing changes when negotiated discounts end.
|
||||
|
||||
Track these separately from organic contraction so board reporting can split pricing-driven and usage-driven changes.
|
||||
|
||||
Use movement_reason on arr_movements when separating discount expiration from churn or seat-reduction events.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
summary: Canonical metrics exclude internal and test accounts and users.
|
||||
tags:
|
||||
- data-quality
|
||||
- governance
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.accounts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
All canonical customer metrics exclude rows marked as internal or test fixtures.
|
||||
|
||||
This exclusion applies at both account and user grain when joining procurement, support, and revenue activity.
|
||||
|
||||
If a metric unexpectedly increases, check whether new internal or test accounts were created without proper flags.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
summary: NRR is calculated at parent-account grain by calendar quarter.
|
||||
tags:
|
||||
- analytics
|
||||
- retention
|
||||
- nrr
|
||||
refs:
|
||||
- arr-contract-first
|
||||
sl_refs:
|
||||
- orbit_demo.arr_movements
|
||||
- orbit_demo.accounts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Net Revenue Retention uses parent-account rollups by calendar quarter.
|
||||
|
||||
The formula is starting ARR plus expansion minus contraction and churn, divided by starting ARR.
|
||||
|
||||
Exclude parent accounts with zero starting ARR, new business, reactivations, and internal/test accounts from the denominator.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Procurement workflow activity measures active requesters and qualifying actions.
|
||||
tags:
|
||||
- product
|
||||
- procurement
|
||||
refs:
|
||||
- activation-policy
|
||||
sl_refs:
|
||||
- orbit_demo.purchase_requests
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Weekly active requesters counts distinct non-internal requesters with a qualifying procurement action in the calendar week.
|
||||
|
||||
Qualifying actions include purchase request creation, approval decisions, supplier invites, and purchase-order creation.
|
||||
|
||||
Purchase-request comments and short sessions are excluded from the canonical requester activity metric.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Gross-to-net revenue reconciles paid invoices, credits, and refunds.
|
||||
tags:
|
||||
- finance
|
||||
- revenue
|
||||
refs:
|
||||
- arr-contract-first
|
||||
sl_refs:
|
||||
- orbit_demo.invoices
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Gross revenue starts from paid invoice activity. Net revenue subtracts credits and successful refunds in the month they are recorded.
|
||||
|
||||
Exclude unpaid, void, draft, failed, internal, and test-account invoice activity from canonical revenue reporting.
|
||||
|
||||
February 2026 has an elevated refund event captured in the source notes and revenue dashboard.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Account segments derive from plan normalization and effective-dated mapping.
|
||||
tags:
|
||||
- sales-ops
|
||||
- segmentation
|
||||
refs: []
|
||||
sl_refs:
|
||||
- orbit_demo.accounts
|
||||
- orbit_demo.contracts
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Account segment labels combine plan_code, canonical_plan_code, and size_band fields.
|
||||
|
||||
Historical plan code pro_plus maps to growth for current segment analysis.
|
||||
|
||||
Use the mapping active at the metric date when segment definitions change over time.
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
summary: Support escalation tiers map ticket severity to SLA targets.
|
||||
tags:
|
||||
- support
|
||||
- sla
|
||||
refs:
|
||||
- customer-health-scoring
|
||||
sl_refs:
|
||||
- orbit_demo.support_tickets
|
||||
usage_mode: auto
|
||||
---
|
||||
|
||||
Critical support tickets require immediate response and on-call escalation.
|
||||
|
||||
High severity tickets should receive first response within four business hours.
|
||||
|
||||
Resolution time is measured from created_at to resolved_at and only applies to resolved tickets.
|
||||
209
packages/cli/assets/demo/orbit/links/provenance.json
Normal file
209
packages/cli/assets/demo/orbit/links/provenance.json
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
[
|
||||
{
|
||||
"id": "link-001",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "contracts",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-002",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/arr-and-contract-reporting-notes.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-003",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "invoices",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-004",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/revenue-reporting-policy.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-005",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/discount-expiration.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "arr_movements",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-006",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "arr_movements",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-007",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/retention-and-nrr-definition-notes.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-008",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"sourceKind": "bi",
|
||||
"sourcePath": "raw-sources/bi/account_retention.view.lkml",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.85
|
||||
},
|
||||
{
|
||||
"id": "link-009",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "plans",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-010",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/sales-ops-segmentation-guide.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-011",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/activation-policy.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/activation-policy-decision-record.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-012",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/procurement-workflows.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "purchase_requests",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-013",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/customer-health-playbook.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-014",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "support_tickets",
|
||||
"relationship": "describes",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-015",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/support-escalation.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/support-escalation-runbook.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-016",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/internal-test-exclusion.md",
|
||||
"sourceKind": "notion",
|
||||
"sourcePath": "raw-sources/notion/analyst-onboarding.md",
|
||||
"relationship": "derived_from",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"id": "link-017",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "accounts",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-018",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"sourceKind": "dbt",
|
||||
"sourcePath": "raw-sources/dbt/schema.yml",
|
||||
"relationship": "inherits_from",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"id": "link-019",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.contracts",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "contracts",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-020",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.invoices",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "invoices",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-021",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.arr_movements",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "arr_movements",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-022",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.purchase_requests",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "purchase_requests",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
},
|
||||
{
|
||||
"id": "link-023",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.support_tickets",
|
||||
"sourceKind": "warehouse",
|
||||
"sourcePath": "support_tickets",
|
||||
"relationship": "models",
|
||||
"confidence": 1
|
||||
}
|
||||
]
|
||||
58
packages/cli/assets/demo/orbit/manifest.json
Normal file
58
packages/cli/assets/demo/orbit/manifest.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"demoAssetSchemaVersion": 2,
|
||||
"name": "orbit",
|
||||
"displayName": "Orbit Demo",
|
||||
"mode": "seeded",
|
||||
"sqliteDatabase": "demo.db",
|
||||
"replay": "replay.memory-flow.v1.json",
|
||||
"report": "reports/seeded-demo-report.json",
|
||||
"source": "packaged-orbit-demo",
|
||||
"sources": {
|
||||
"warehouse": {
|
||||
"label": "Warehouse",
|
||||
"path": "demo.db",
|
||||
"tables": 8,
|
||||
"rowCounts": {
|
||||
"accounts": 210,
|
||||
"contracts": 320,
|
||||
"users": 1260,
|
||||
"invoices": 3000,
|
||||
"arr_movements": 720,
|
||||
"support_tickets": 520,
|
||||
"purchase_requests": 5200,
|
||||
"plans": 4
|
||||
}
|
||||
},
|
||||
"dbt": {
|
||||
"label": "dbt",
|
||||
"path": "raw-sources/dbt",
|
||||
"models": 3,
|
||||
"sourceTables": 8
|
||||
},
|
||||
"bi": {
|
||||
"label": "BI",
|
||||
"path": "raw-sources/bi",
|
||||
"explores": 5,
|
||||
"dashboards": 2
|
||||
},
|
||||
"notion": {
|
||||
"label": "Notion",
|
||||
"path": "raw-sources/notion",
|
||||
"pages": 8
|
||||
}
|
||||
},
|
||||
"generated": {
|
||||
"semanticLayer": {
|
||||
"path": "semantic-layer/orbit_demo",
|
||||
"sourceCount": 6
|
||||
},
|
||||
"knowledge": {
|
||||
"path": "knowledge/global",
|
||||
"pageCount": 10
|
||||
},
|
||||
"links": {
|
||||
"path": "links",
|
||||
"linkCount": 23
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
view: account_retention {
|
||||
sql_table_name: orbit_analytics.mart_nrr_quarterly ;;
|
||||
description: "Canonical dbt mart dbt://ktx_demo.mart_nrr_quarterly with governed policy notion://notion_page_retention_policy_current#nrr-definition."
|
||||
|
||||
dimension: retention_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT(${TABLE}.segment, '-', ${TABLE}.quarter_label) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: ${TABLE}.segment ;;
|
||||
}
|
||||
|
||||
dimension: parent_account_id {
|
||||
type: string
|
||||
sql: ${TABLE}.segment ;;
|
||||
}
|
||||
|
||||
dimension: fiscal_quarter {
|
||||
type: string
|
||||
sql: ${TABLE}.quarter_label ;;
|
||||
}
|
||||
|
||||
dimension: segment {
|
||||
type: string
|
||||
sql: ${TABLE}.segment ;;
|
||||
}
|
||||
|
||||
dimension: net_revenue_retention {
|
||||
type: number
|
||||
sql: ${TABLE}.net_revenue_retention ;;
|
||||
}
|
||||
|
||||
measure: nrr {
|
||||
type: average
|
||||
sql: ${net_revenue_retention} ;;
|
||||
value_format_name: percent_1
|
||||
description: "Enterprise parent-account NRR from dbt://ktx_demo.mart_nrr_quarterly and notion://notion_page_retention_policy_current#nrr-definition."
|
||||
}
|
||||
|
||||
measure: starting_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.starting_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: expansion_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.expansion_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Expansion ARR used by the enterprise_expansions_q1_2026 expected answer."
|
||||
}
|
||||
|
||||
measure: contraction_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.contraction_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: churned_arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.churned_arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
view: arr_daily {
|
||||
sql_table_name: orbit_analytics.mart_arr_daily ;;
|
||||
description: "Contract-first ARR from dbt://ktx_demo.mart_arr_daily and notion://notion_page_arr_contract_reporting#arr-contract-first."
|
||||
|
||||
dimension: arr_daily_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT('all_accounts-', ${TABLE}.metric_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: 'all_accounts' ;;
|
||||
}
|
||||
|
||||
dimension_group: metric {
|
||||
type: time
|
||||
timeframes: [date, week, month, quarter]
|
||||
sql: ${TABLE}.metric_date ;;
|
||||
}
|
||||
|
||||
measure: arr {
|
||||
type: sum
|
||||
sql: ${TABLE}.arr_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Active contract ARR as of the requested metric date."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
view: customer_health {
|
||||
sql_table_name: orbit_analytics.mart_customer_health ;;
|
||||
description: "Customer health mart dbt://ktx_demo.mart_customer_health governed by notion://notion_page_customer_health_playbook#risk-definition."
|
||||
|
||||
dimension: customer_health_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT(${TABLE}.account_id, '-', ${TABLE}.as_of_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: ${TABLE}.account_id ;;
|
||||
}
|
||||
|
||||
dimension_group: metric {
|
||||
type: time
|
||||
timeframes: [date, week, month]
|
||||
sql: ${TABLE}.as_of_date ;;
|
||||
}
|
||||
|
||||
dimension: health_risk_tier {
|
||||
type: string
|
||||
sql: ${TABLE}.risk_level ;;
|
||||
}
|
||||
|
||||
dimension: is_paying_customer {
|
||||
type: yesno
|
||||
sql: ${TABLE}.is_active_customer ;;
|
||||
}
|
||||
|
||||
measure: active_customers {
|
||||
type: count_distinct
|
||||
sql: ${account_id} ;;
|
||||
filters: [is_paying_customer: "yes"]
|
||||
description: "Active paying customer accounts in the health mart."
|
||||
}
|
||||
|
||||
measure: high_risk_accounts {
|
||||
type: count_distinct
|
||||
sql: ${account_id} ;;
|
||||
filters: [health_risk_tier: "high"]
|
||||
description: "High-risk paying accounts used by the customer_health_risk_accounts expected answer."
|
||||
}
|
||||
|
||||
measure: open_support_tickets {
|
||||
type: sum
|
||||
sql: case when ${TABLE}.has_unresolved_high_ticket then 1 else 0 end ;;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
view: procurement_activity {
|
||||
sql_table_name: orbit_analytics.mart_procurement_activity ;;
|
||||
description: "Procurement activity mart dbt://ktx_demo.mart_procurement_activity with governed context notion://notion_page_procurement_instrumentation#qualifying-procurement-actions."
|
||||
|
||||
dimension: procurement_activity_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT(${TABLE}.contract_arr_threshold_cents, '-', ${TABLE}.week_start_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: 'all_accounts' ;;
|
||||
}
|
||||
|
||||
dimension_group: week_start {
|
||||
type: time
|
||||
timeframes: [date, week]
|
||||
sql: ${TABLE}.week_start_date ;;
|
||||
}
|
||||
|
||||
dimension: contract_arr_band {
|
||||
type: string
|
||||
sql: case
|
||||
when ${TABLE}.contract_arr_threshold_cents >= 20000000 then 'over_200k'
|
||||
else 'under_200k'
|
||||
end ;;
|
||||
description: "Contract ARR band represented by the procurement activity threshold."
|
||||
}
|
||||
|
||||
measure: weekly_active_requesters {
|
||||
type: sum
|
||||
sql: ${TABLE}.active_requesters ;;
|
||||
description: "Distinct non-internal requesters with qualifying procurement workflow actions during the requested week."
|
||||
}
|
||||
|
||||
measure: purchase_requests {
|
||||
type: sum
|
||||
sql: 0 ;;
|
||||
}
|
||||
|
||||
measure: approval_actions {
|
||||
type: sum
|
||||
sql: 0 ;;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# looker_dashboard_id: dash_retention_exec_q1
|
||||
dashboard: retention_exec_q1 {
|
||||
title: "Enterprise Retention Executive Review"
|
||||
|
||||
element: retention_tile {
|
||||
title: "Enterprise NRR"
|
||||
explore: retention
|
||||
fields: [retention.fiscal_quarter, retention.nrr]
|
||||
}
|
||||
|
||||
element: movement_breakout_tile {
|
||||
title: "Movement Breakout"
|
||||
explore: retention
|
||||
fields: [retention.expansion_arr, retention.contraction_arr, retention.churned_arr]
|
||||
}
|
||||
|
||||
element: discount_expiration_contraction_tile {
|
||||
title: "Discount Expiration Contraction"
|
||||
explore: retention
|
||||
fields: [retention.parent_account_id, retention.contraction_arr]
|
||||
}
|
||||
|
||||
element: q4_vs_q1_comparison_tile {
|
||||
title: "Q4 vs Q1 Comparison"
|
||||
explore: retention
|
||||
fields: [retention.fiscal_quarter, retention.nrr]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
view: revenue_daily {
|
||||
sql_table_name: orbit_analytics.mart_revenue_daily ;;
|
||||
description: "Revenue recognition mart dbt://ktx_demo.mart_revenue_daily governed by notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation."
|
||||
|
||||
dimension: revenue_daily_key {
|
||||
primary_key: yes
|
||||
type: string
|
||||
sql: CONCAT('all_accounts-', ${TABLE}.revenue_date) ;;
|
||||
}
|
||||
|
||||
dimension: account_id {
|
||||
type: string
|
||||
sql: 'all_accounts' ;;
|
||||
}
|
||||
|
||||
dimension_group: revenue {
|
||||
type: time
|
||||
timeframes: [date, week, quarter]
|
||||
sql: ${TABLE}.revenue_date ;;
|
||||
}
|
||||
|
||||
dimension: revenue_month {
|
||||
type: string
|
||||
sql: TO_CHAR(${TABLE}.revenue_date, 'YYYY-MM') ;;
|
||||
}
|
||||
|
||||
measure: gross_revenue {
|
||||
type: sum
|
||||
sql: ${TABLE}.gross_revenue_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Paid invoice line revenue before credits and refunds."
|
||||
}
|
||||
|
||||
measure: credits {
|
||||
type: sum
|
||||
sql: ${TABLE}.credits_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: refunds {
|
||||
type: sum
|
||||
sql: ${TABLE}.refunds_cents ;;
|
||||
value_format_name: usd_0
|
||||
}
|
||||
|
||||
measure: net_revenue {
|
||||
type: sum
|
||||
sql: ${TABLE}.net_revenue_cents ;;
|
||||
value_format_name: usd_0
|
||||
description: "Gross revenue minus credits and successful refunds, recognized by paid/refund dates."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# looker_dashboard_id: dash_revenue_exec
|
||||
dashboard: revenue_exec {
|
||||
title: "Gross and Net Revenue Executive Dashboard"
|
||||
|
||||
element: gross_revenue_tile {
|
||||
title: "Gross Revenue"
|
||||
explore: revenue
|
||||
fields: [revenue.revenue_month, revenue.gross_revenue]
|
||||
}
|
||||
|
||||
element: credits_tile {
|
||||
title: "Credits"
|
||||
explore: revenue
|
||||
fields: [revenue.revenue_month, revenue.credits]
|
||||
}
|
||||
|
||||
element: refunds_tile {
|
||||
title: "Refunds"
|
||||
explore: revenue
|
||||
fields: [revenue.revenue_month, revenue.refunds]
|
||||
}
|
||||
|
||||
element: february_reconciliation_tile {
|
||||
title: "February Reconciliation"
|
||||
explore: revenue
|
||||
fields: [revenue.gross_revenue, revenue.credits, revenue.refunds, revenue.net_revenue]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
name: ktx_demo
|
||||
version: "1.0.0"
|
||||
config-version: 2
|
||||
profile: ktx_demo
|
||||
|
||||
model-paths: ["models"]
|
||||
|
||||
models:
|
||||
ktx_demo:
|
||||
+materialized: view
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
select
|
||||
date '2026-03-31' as metric_date,
|
||||
sum(contract_arr_cents)::bigint as arr_cents,
|
||||
'$18.742M' as display
|
||||
from {{ ref('int_active_contract_arr') }}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
select
|
||||
date '2026-03-31' as as_of_date,
|
||||
account_id,
|
||||
parent_account_id,
|
||||
account_name,
|
||||
is_active_customer,
|
||||
has_unresolved_high_ticket,
|
||||
has_recent_procurement_activity,
|
||||
risk_level
|
||||
from {{ ref('int_customer_health_signals') }}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
select
|
||||
revenue_date,
|
||||
gross_revenue_cents::bigint as gross_revenue_cents,
|
||||
credits_cents::bigint as credits_cents,
|
||||
refunds_cents::bigint as refunds_cents,
|
||||
net_revenue_cents::bigint as net_revenue_cents,
|
||||
(gross_revenue_cents - credits_cents - refunds_cents = net_revenue_cents) as reconciliation_check
|
||||
from {{ ref('int_revenue_components') }}
|
||||
455
packages/cli/assets/demo/orbit/raw-sources/dbt/schema.yml
Normal file
455
packages/cli/assets/demo/orbit/raw-sources/dbt/schema.yml
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
version: 2
|
||||
|
||||
models:
|
||||
- name: stg_accounts
|
||||
description: 'Customer and internal/test account records for Orbit.'
|
||||
columns:
|
||||
- name: account_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: sales_region
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [na, emea, apac]
|
||||
- name: size_band
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [smb, mid_market, enterprise]
|
||||
- name: lifecycle_status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [prospect, active, churned, internal, test]
|
||||
- name: stg_account_hierarchy
|
||||
description: 'Parent-child account relationships used for enterprise retention grain.'
|
||||
columns:
|
||||
- name: account_hierarchy_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: relationship_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [subsidiary, division, billing_group]
|
||||
- name: stg_plans
|
||||
description: 'Canonical and historical Orbit pricing plans.'
|
||||
columns:
|
||||
- name: plan_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: canonical_plan_code
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [starter, growth, enterprise]
|
||||
- name: stg_contracts
|
||||
description: 'Contract records that provide contract-first ARR for active accounts.'
|
||||
columns:
|
||||
- name: contract_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [draft, active, cancelled, expired]
|
||||
- name: renewal_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [new, renewal, expansion, downgrade]
|
||||
- name: stg_subscriptions
|
||||
description: 'Subscription rows used when active contract ARR is not present for a covered period.'
|
||||
columns:
|
||||
- name: subscription_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [active, cancelled, past_due, trialing]
|
||||
- name: stg_contract_discount_terms
|
||||
description: 'Contract discount terms that explain Q1 2026 enterprise contraction movement.'
|
||||
columns:
|
||||
- name: discount_term_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: discount_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [launch, renewal, migration, goodwill]
|
||||
- name: stg_arr_movements
|
||||
description: 'ARR movement ledger used by retention and expansion marts.'
|
||||
columns:
|
||||
- name: arr_movement_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: movement_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [new, expansion, contraction, churn, reactivation]
|
||||
- name: stg_invoices
|
||||
description: 'Billing invoices that anchor gross revenue recognition dates.'
|
||||
columns:
|
||||
- name: invoice_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [draft, open, paid, void, failed]
|
||||
- name: currency
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [USD]
|
||||
- name: stg_invoice_line_items
|
||||
description: 'Invoice line items used to split gross revenue, credits, seats, usage, and addons.'
|
||||
columns:
|
||||
- name: invoice_line_item_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: line_item_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [subscription, seat, usage, addon, credit]
|
||||
- name: stg_refunds
|
||||
description: 'Refund events that reduce net revenue in the refund month.'
|
||||
columns:
|
||||
- name: refund_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [pending, succeeded, failed, cancelled]
|
||||
- name: stg_plan_segment_mapping
|
||||
description: 'Effective-dated mapping from canonical plans and size bands to reporting segments.'
|
||||
columns:
|
||||
- name: plan_segment_mapping_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: canonical_plan_code
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [starter, growth, enterprise]
|
||||
- name: size_band
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [smb, mid_market, enterprise]
|
||||
- name: segment
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [self_serve, commercial, enterprise]
|
||||
- name: stg_users
|
||||
description: 'Orbit user identities shared across warehouse, Slack, Looker, Notion, and Drive artifacts.'
|
||||
columns:
|
||||
- name: user_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: stg_activation_events
|
||||
description: 'Account and requester activation events across the January policy change.'
|
||||
columns:
|
||||
- name: activation_event_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: event_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [first_requester_login, requester_activated, first_approved_purchase_request, account_activated]
|
||||
- name: policy_version
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [pre_2026_01_15, post_2026_01_15]
|
||||
- name: stg_sessions
|
||||
description: 'Product sessions used for pre-policy activation and activity exclusions.'
|
||||
columns:
|
||||
- name: session_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: stg_purchase_requests
|
||||
description: 'Procurement request records used for activation, requester activity, and health signals.'
|
||||
columns:
|
||||
- name: purchase_request_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [draft, submitted, approved, rejected, cancelled]
|
||||
- name: stg_approval_events
|
||||
description: 'Approval decisions tied to procurement requests.'
|
||||
columns:
|
||||
- name: approval_event_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: decision
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [approved, rejected, returned]
|
||||
- name: stg_suppliers
|
||||
description: 'Supplier directory records associated with procurement workflow events.'
|
||||
columns:
|
||||
- name: supplier_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [invited, onboarding, active, inactive]
|
||||
- name: stg_supplier_onboarding_events
|
||||
description: 'Supplier onboarding milestones that qualify as procurement workflow activity.'
|
||||
columns:
|
||||
- name: supplier_onboarding_event_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: event_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [invited, profile_started, profile_completed, approved]
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [pending, completed, blocked]
|
||||
- name: stg_purchase_orders
|
||||
description: 'Purchase orders generated from approved procurement requests.'
|
||||
columns:
|
||||
- name: purchase_order_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [created, sent, fulfilled, cancelled]
|
||||
- name: stg_support_tickets
|
||||
description: 'Customer support tickets that inform account health and risk.'
|
||||
columns:
|
||||
- name: support_ticket_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: severity
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [low, medium, high, critical]
|
||||
- name: status
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [open, pending, solved, closed]
|
||||
- name: stg_account_owners
|
||||
description: 'Effective-dated ownership assignments for account health, renewals, and escalation context.'
|
||||
columns:
|
||||
- name: account_owner_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: owner_team
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [sales_ops, customer_success, finance]
|
||||
- name: int_active_contract_arr
|
||||
description: Active contract ARR as of 2026-03-31.
|
||||
columns:
|
||||
- name: contract_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: int_parent_account_arr_movements
|
||||
description: Parent-account movement rollups for retention metrics.
|
||||
columns:
|
||||
- name: arr_movement_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: movement_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [new, expansion, contraction, churn, reactivation]
|
||||
- name: is_discount_expiration_contraction
|
||||
description: Discount expiration contraction flag used to keep discount movement separate from churn.
|
||||
- name: int_revenue_components
|
||||
description: Daily gross, credit, refund, and net revenue components.
|
||||
- name: int_procurement_qualifying_actions
|
||||
description: Non-internal, non-test requester activity for large active contracts in the golden week.
|
||||
- name: int_activation_policy_windows
|
||||
description: Activation cohort counts around the January 2026 policy change.
|
||||
- name: int_customer_health_signals
|
||||
description: Support-ticket and recent-procurement signals for customer health risk.
|
||||
- name: mart_arr_daily
|
||||
description: Board-prep ARR as of the metric date.
|
||||
meta:
|
||||
governed_metric_key: arr
|
||||
owner_team: finance
|
||||
notion_locator: notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
expected_answer: expected-answer://arr_as_of_2026_03_31
|
||||
columns:
|
||||
- name: metric_date
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: arr_cents
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [1874200000]
|
||||
quote: false
|
||||
- name: mart_nrr_quarterly
|
||||
description: Enterprise quarterly net revenue retention.
|
||||
meta:
|
||||
governed_metric_key: net_revenue_retention
|
||||
owner_team: analytics
|
||||
notion_locator: notion://notion_page_retention_policy_current#nrr-definition
|
||||
expected_answer: expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
columns:
|
||||
- name: quarter_label
|
||||
data_tests:
|
||||
- not_null
|
||||
- name: net_revenue_retention
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [1.018]
|
||||
quote: false
|
||||
config:
|
||||
where: "quarter_label = '2026-Q1' and segment = 'enterprise'"
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [1.064]
|
||||
quote: false
|
||||
config:
|
||||
where: "quarter_label = '2025-Q4' and segment = 'enterprise'"
|
||||
- name: mart_retention_movement_breakout
|
||||
description: Q1 2026 enterprise retention movement breakout.
|
||||
meta:
|
||||
governed_metric_key: net_revenue_retention
|
||||
owner_team: analytics
|
||||
notion_locator: notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
expected_answer: expected-answer://enterprise_expansions_q1_2026
|
||||
columns:
|
||||
- name: movement_type
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [expansion, contraction, churn]
|
||||
- name: movement_reason
|
||||
description: Includes discount_expiration contraction, which is not churn.
|
||||
- name: parent_account_count
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [11]
|
||||
quote: false
|
||||
config:
|
||||
where: "movement_type = 'contraction' and movement_reason = 'discount_expiration'"
|
||||
- name: expansion_arr_cents
|
||||
description: Expansion ARR cents for Q1 enterprise movement rows.
|
||||
- name: mart_revenue_daily
|
||||
description: Daily revenue mart that reconciles gross, credits, refunds, and net revenue.
|
||||
meta:
|
||||
governed_metric_key: net_revenue
|
||||
owner_team: finance
|
||||
notion_locator: notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
expected_answer: expected-answer://revenue_net_vs_gross_reconciliation
|
||||
columns:
|
||||
- name: revenue_date
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: reconciliation_check
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [true]
|
||||
quote: false
|
||||
- name: net_revenue_cents
|
||||
description: Daily net revenue in cents; February 2026 total is covered by assert_february_2026_net_revenue.
|
||||
- name: mart_account_activity
|
||||
description: Activation policy comparison values.
|
||||
meta:
|
||||
governed_metric_key: activated_accounts
|
||||
owner_team: growth
|
||||
notion_locator: notion://notion_page_activation_policy_decision#policy-change
|
||||
expected_answer: expected-answer://activation_after_policy_change
|
||||
- name: mart_procurement_activity
|
||||
description: Weekly active requester counts for large active contracts.
|
||||
meta:
|
||||
governed_metric_key: weekly_active_requesters
|
||||
owner_team: product
|
||||
notion_locator: notion://notion_page_procurement_instrumentation#qualifying-procurement-actions
|
||||
expected_answer: expected-answer://active_requesters_last_week_large_contracts
|
||||
columns:
|
||||
- name: active_requesters
|
||||
description: Weekly active requesters for large active contracts.
|
||||
- name: mart_customer_health
|
||||
description: Customer-health risk mart as of 2026-03-31.
|
||||
meta:
|
||||
governed_metric_key: active_customers
|
||||
owner_team: customer_success
|
||||
notion_locator: notion://notion_page_customer_health_playbook#risk-definition
|
||||
expected_answer: expected-answer://customer_health_risk_accounts
|
||||
columns:
|
||||
- name: account_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: risk_level
|
||||
data_tests:
|
||||
- accepted_values:
|
||||
arguments:
|
||||
values: [low, medium, high]
|
||||
- name: mart_account_segments
|
||||
description: Current plan, size band, and reporting segment for accounts.
|
||||
meta:
|
||||
governed_metric_key: segment
|
||||
owner_team: sales_ops
|
||||
notion_locator: notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
expected_answer: expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
columns:
|
||||
- name: account_id
|
||||
data_tests:
|
||||
- not_null
|
||||
- unique
|
||||
- name: normalized_plan_code
|
||||
description: pro_plus is normalized to growth through plans.canonical_plan_code.
|
||||
48
packages/cli/assets/demo/orbit/raw-sources/dbt/sources.yml
Normal file
48
packages/cli/assets/demo/orbit/raw-sources/dbt/sources.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
version: 2
|
||||
|
||||
sources:
|
||||
- name: orbit_raw
|
||||
schema: orbit_raw
|
||||
tables:
|
||||
- name: accounts
|
||||
description: 'Customer and internal/test account records for Orbit.'
|
||||
- name: account_hierarchy
|
||||
description: 'Parent-child account relationships used for enterprise retention grain.'
|
||||
- name: plans
|
||||
description: 'Canonical and historical Orbit pricing plans.'
|
||||
- name: contracts
|
||||
description: 'Contract records that provide contract-first ARR for active accounts.'
|
||||
- name: subscriptions
|
||||
description: 'Subscription rows used when active contract ARR is not present for a covered period.'
|
||||
- name: contract_discount_terms
|
||||
description: 'Contract discount terms that explain Q1 2026 enterprise contraction movement.'
|
||||
- name: arr_movements
|
||||
description: 'ARR movement ledger used by retention and expansion marts.'
|
||||
- name: invoices
|
||||
description: 'Billing invoices that anchor gross revenue recognition dates.'
|
||||
- name: invoice_line_items
|
||||
description: 'Invoice line items used to split gross revenue, credits, seats, usage, and addons.'
|
||||
- name: refunds
|
||||
description: 'Refund events that reduce net revenue in the refund month.'
|
||||
- name: plan_segment_mapping
|
||||
description: 'Effective-dated mapping from canonical plans and size bands to reporting segments.'
|
||||
- name: users
|
||||
description: 'Orbit user identities shared across warehouse, Slack, Looker, Notion, and Drive artifacts.'
|
||||
- name: activation_events
|
||||
description: 'Account and requester activation events across the January policy change.'
|
||||
- name: sessions
|
||||
description: 'Product sessions used for pre-policy activation and activity exclusions.'
|
||||
- name: purchase_requests
|
||||
description: 'Procurement request records used for activation, requester activity, and health signals.'
|
||||
- name: approval_events
|
||||
description: 'Approval decisions tied to procurement requests.'
|
||||
- name: suppliers
|
||||
description: 'Supplier directory records associated with procurement workflow events.'
|
||||
- name: supplier_onboarding_events
|
||||
description: 'Supplier onboarding milestones that qualify as procurement workflow activity.'
|
||||
- name: purchase_orders
|
||||
description: 'Purchase orders generated from approved procurement requests.'
|
||||
- name: support_tickets
|
||||
description: 'Customer support tickets that inform account health and risk.'
|
||||
- name: account_owners
|
||||
description: 'Effective-dated ownership assignments for account health, renewals, and escalation context.'
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
page_id: notion_page_activation_policy_decision
|
||||
title: 'Activation Policy Decision Record'
|
||||
owner_person_key: leo_martin
|
||||
owner_team: growth
|
||||
owner_notion_user_id: notion_user_0003
|
||||
status: current
|
||||
created_time: 2026-01-10T14:00:00-08:00
|
||||
last_edited_time: 2026-02-18T11:10:00-08:00
|
||||
tags:
|
||||
- growth
|
||||
- activation
|
||||
- policy
|
||||
related_expected_answers:
|
||||
- activation_after_policy_change
|
||||
related_metric_keys:
|
||||
- activated_accounts
|
||||
anchors:
|
||||
- notion://notion_page_activation_policy_decision#policy-change
|
||||
---
|
||||
|
||||
# Activation Policy Decision Record
|
||||
|
||||
Owner: Leo Martin (growth)
|
||||
|
||||
## Policy Change
|
||||
Anchor: notion://notion_page_activation_policy_decision#policy-change
|
||||
|
||||
Before 2026-01-15, account activation means first requester login.
|
||||
|
||||
On and after 2026-01-15, account activation means first approved purchase request plus at least three activated requesters.
|
||||
|
||||
Activated requesters are non-internal, non-test requester users with either a qualifying session before the policy date or a qualifying procurement action after it.
|
||||
|
||||
The governed comparison reports a 0.563 pre-policy 30-day activation rate and a 0.639 post-policy 30-day activation rate.
|
||||
|
||||
## Pre-Change Definition
|
||||
Anchor: notion://notion_page_activation_policy_decision#pre-change-definition
|
||||
|
||||
## Post-Change Definition
|
||||
Anchor: notion://notion_page_activation_policy_decision#post-change-definition
|
||||
|
||||
## Dashboard Impact
|
||||
Anchor: notion://notion_page_activation_policy_decision#dashboard-impact
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_activation_policy_decision#policy-change
|
||||
- expected-answer://activation_after_policy_change
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
page_id: notion_page_analyst_onboarding
|
||||
title: 'Analyst Onboarding'
|
||||
owner_person_key: maya_chen
|
||||
owner_team: analytics
|
||||
owner_notion_user_id: notion_user_0001
|
||||
status: current
|
||||
created_time: 2026-03-02T09:00:00-08:00
|
||||
last_edited_time: 2026-03-17T15:30:00-07:00
|
||||
parent_page_id: notion_page_analytics_team_handbook
|
||||
tags:
|
||||
- analytics
|
||||
- onboarding
|
||||
source_anchors:
|
||||
- notion://notion_page_analyst_onboarding#first-week
|
||||
- lookml://orbit/account_retention.view.lkml#measure=nrr
|
||||
- slack://analytics-team/2026-03-31/1774942174.200142?thread_ts=1774942174.200142
|
||||
---
|
||||
|
||||
# Analyst Onboarding
|
||||
|
||||
Owner: Maya Chen (analytics)
|
||||
|
||||
## Operating Context
|
||||
Anchor: notion://notion_page_analyst_onboarding#analyst-onboarding
|
||||
|
||||
New analysts start with dbt://ktx_demo.mart_arr_daily and then review LookML field ownership.
|
||||
Do not answer metric questions from raw tables when lookml://orbit/account_retention.view.lkml#measure=nrr or a governed mart exists.
|
||||
Escalate unclear board-week requests in slack://analytics-team/2026-03-31/1774942174.200142?thread_ts=1774942174.200142.
|
||||
|
||||
## Source Anchors
|
||||
|
||||
- notion://notion_page_analyst_onboarding#first-week
|
||||
- lookml://orbit/account_retention.view.lkml#measure=nrr
|
||||
- slack://analytics-team/2026-03-31/1774942174.200142?thread_ts=1774942174.200142
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
page_id: notion_page_arr_contract_reporting
|
||||
title: 'ARR and Contract Reporting Notes'
|
||||
owner_person_key: rina_patel
|
||||
owner_team: finance
|
||||
owner_notion_user_id: notion_user_0002
|
||||
status: current
|
||||
created_time: 2025-11-20T10:30:00-08:00
|
||||
last_edited_time: 2026-03-26T15:05:00-07:00
|
||||
tags:
|
||||
- finance
|
||||
- arr
|
||||
- contracts
|
||||
related_expected_answers:
|
||||
- arr_as_of_2026_03_31
|
||||
- active_requesters_last_week_large_contracts
|
||||
related_metric_keys:
|
||||
- arr
|
||||
- contract_arr_band
|
||||
anchors:
|
||||
- notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
- notion://notion_page_arr_contract_reporting#contract-arr-band
|
||||
---
|
||||
|
||||
# ARR and Contract Reporting Notes
|
||||
|
||||
Owner: Rina Patel (finance)
|
||||
|
||||
## ARR Contract First
|
||||
Anchor: notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
|
||||
ARR uses active contract_arr_cents first when a contract covers the account and metric date.
|
||||
|
||||
Recurring subscription MRR is annualized only for account periods without active contract ARR.
|
||||
|
||||
Contract ARR banding uses active contract ARR as of 2026-03-31, including the contracts over 20000000 cents threshold.
|
||||
|
||||
Booked ARR is not active ARR, and internal or test accounts are excluded from board reporting.
|
||||
|
||||
## Contract ARR Band
|
||||
Anchor: notion://notion_page_arr_contract_reporting#contract-arr-band
|
||||
|
||||
ARR uses active contract_arr_cents first when a contract covers the account and metric date.
|
||||
|
||||
Recurring subscription MRR is annualized only for account periods without active contract ARR.
|
||||
|
||||
Contract ARR banding uses active contract ARR as of 2026-03-31, including the contracts over 20000000 cents threshold.
|
||||
|
||||
## Booked ARR vs Active ARR
|
||||
Anchor: notion://notion_page_arr_contract_reporting#booked-arr-vs-active-arr
|
||||
|
||||
Booked ARR is not active ARR, and internal or test accounts are excluded from board reporting.
|
||||
|
||||
## Internal and Test Exclusions
|
||||
Anchor: notion://notion_page_arr_contract_reporting#internal-and-test-exclusions
|
||||
|
||||
Booked ARR is not active ARR, and internal or test accounts are excluded from board reporting.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_arr_contract_reporting#arr-contract-first
|
||||
- notion://notion_page_arr_contract_reporting#contract-arr-band
|
||||
- expected-answer://arr_as_of_2026_03_31
|
||||
- expected-answer://active_requesters_last_week_large_contracts
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
page_id: notion_page_customer_health_playbook
|
||||
title: 'Customer Health Playbook'
|
||||
owner_person_key: priya_shah
|
||||
owner_team: customer_success
|
||||
owner_notion_user_id: notion_user_0005
|
||||
status: current
|
||||
created_time: 2026-02-03T13:45:00-08:00
|
||||
last_edited_time: 2026-03-27T14:50:00-07:00
|
||||
tags:
|
||||
- customer-success
|
||||
- health
|
||||
- risk
|
||||
related_expected_answers:
|
||||
- customer_health_risk_accounts
|
||||
related_metric_keys:
|
||||
- active_customers
|
||||
anchors:
|
||||
- notion://notion_page_customer_health_playbook#risk-definition
|
||||
---
|
||||
|
||||
# Customer Health Playbook
|
||||
|
||||
Owner: Priya Shah (customer_success)
|
||||
|
||||
## Risk Definition
|
||||
Anchor: notion://notion_page_customer_health_playbook#risk-definition
|
||||
|
||||
Customer health combines support ticket severity and recent requisition or approval usage.
|
||||
|
||||
Active customers must have an active paid subscription and at least one qualifying procurement action in the trailing 30-day window.
|
||||
|
||||
High-risk accounts are reviewed for renewal action when severe open tickets coincide with falling workflow activity.
|
||||
|
||||
As of 2026-03-31 the governed customer-health mart reports 9 high-risk accounts.
|
||||
|
||||
## Support Signals
|
||||
Anchor: notion://notion_page_customer_health_playbook#support-signals
|
||||
|
||||
Customer health combines support ticket severity and recent requisition or approval usage.
|
||||
|
||||
## Procurement Activity Signals
|
||||
Anchor: notion://notion_page_customer_health_playbook#procurement-activity-signals
|
||||
|
||||
Active customers must have an active paid subscription and at least one qualifying procurement action in the trailing 30-day window.
|
||||
|
||||
## Renewal Review
|
||||
Anchor: notion://notion_page_customer_health_playbook#renewal-review
|
||||
|
||||
High-risk accounts are reviewed for renewal action when severe open tickets coincide with falling workflow activity.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_customer_health_playbook#risk-definition
|
||||
- expected-answer://customer_health_risk_accounts
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
page_id: notion_page_retention_policy_current
|
||||
title: 'Retention and NRR Definition Notes'
|
||||
owner_person_key: maya_chen
|
||||
owner_team: analytics
|
||||
owner_notion_user_id: notion_user_0001
|
||||
status: current
|
||||
created_time: 2026-01-08T10:00:00-08:00
|
||||
last_edited_time: 2026-03-30T16:40:00-07:00
|
||||
tags:
|
||||
- analytics
|
||||
- retention
|
||||
- board-reporting
|
||||
related_expected_answers:
|
||||
- enterprise_nrr_q1_vs_q4_breakout
|
||||
- enterprise_expansions_q1_2026
|
||||
related_metric_keys:
|
||||
- net_revenue_retention
|
||||
- segment
|
||||
anchors:
|
||||
- notion://notion_page_retention_policy_current#nrr-definition
|
||||
- notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
---
|
||||
|
||||
# Retention and NRR Definition Notes
|
||||
|
||||
Owner: Maya Chen (analytics)
|
||||
|
||||
## NRR Definition
|
||||
Anchor: notion://notion_page_retention_policy_current#nrr-definition
|
||||
|
||||
Enterprise NRR is calculated as (starting_arr + expansion_arr - contraction_arr - churned_arr) / starting_arr.
|
||||
|
||||
Movement classification happens after child accounts roll up to parent_account_id.
|
||||
|
||||
Reactivations within 30 days are excluded from NRR movement components and kept in audit columns.
|
||||
|
||||
Q1 2026 discount expiration is contraction, not churn; the board-prep view calls out 11 enterprise parent accounts.
|
||||
|
||||
## Parent-Account Grain
|
||||
Anchor: notion://notion_page_retention_policy_current#parent-account-grain
|
||||
|
||||
## Reactivation Exclusion
|
||||
Anchor: notion://notion_page_retention_policy_current#reactivation-exclusion
|
||||
|
||||
Reactivations within 30 days are excluded from NRR movement components and kept in audit columns.
|
||||
|
||||
## Discount Expiration Treatment
|
||||
Anchor: notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
|
||||
Q1 2026 discount expiration is contraction, not churn; the board-prep view calls out 11 enterprise parent accounts.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_retention_policy_current#nrr-definition
|
||||
- notion://notion_page_retention_policy_current#discount-expiration-treatment
|
||||
- expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
- expected-answer://enterprise_expansions_q1_2026
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
page_id: notion_page_revenue_reporting_policy
|
||||
title: 'Revenue Reporting Policy'
|
||||
owner_person_key: rina_patel
|
||||
owner_team: finance
|
||||
owner_notion_user_id: notion_user_0002
|
||||
status: current
|
||||
created_time: 2025-12-12T09:30:00-08:00
|
||||
last_edited_time: 2026-03-28T13:15:00-07:00
|
||||
tags:
|
||||
- finance
|
||||
- revenue
|
||||
- board-reporting
|
||||
related_expected_answers:
|
||||
- revenue_net_vs_gross_reconciliation
|
||||
related_metric_keys:
|
||||
- gross_revenue
|
||||
- net_revenue
|
||||
anchors:
|
||||
- notion://notion_page_revenue_reporting_policy#gross-revenue
|
||||
- notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
---
|
||||
|
||||
# Revenue Reporting Policy
|
||||
|
||||
Owner: Rina Patel (finance)
|
||||
|
||||
## Gross Revenue
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#gross-revenue
|
||||
|
||||
Gross revenue includes paid subscription, seat, usage, and addon invoice line items recognized on invoices.paid_at.
|
||||
|
||||
Credit line items are negative in raw invoice lines and reported as absolute credits.
|
||||
|
||||
Successful refunds reduce net revenue in the refund month based on refunds.refunded_at.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Credits
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#credits
|
||||
|
||||
Credit line items are negative in raw invoice lines and reported as absolute credits.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Refunds
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#refunds
|
||||
|
||||
Successful refunds reduce net revenue in the refund month based on refunds.refunded_at.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Gross To Net Reconciliation
|
||||
Anchor: notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
|
||||
Gross revenue includes paid subscription, seat, usage, and addon invoice line items recognized on invoices.paid_at.
|
||||
|
||||
For February 2026 the governed reconciliation is gross revenue 213000000 cents, credits 13400000 cents, refunds 31200000 cents, and net revenue 168400000 cents.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_revenue_reporting_policy#gross-revenue
|
||||
- notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation
|
||||
- expected-answer://revenue_net_vs_gross_reconciliation
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
page_id: notion_page_sales_ops_segmentation
|
||||
title: 'Sales Ops Segmentation Guide'
|
||||
owner_person_key: jordan_lee
|
||||
owner_team: sales_ops
|
||||
owner_notion_user_id: notion_user_0004
|
||||
status: current
|
||||
created_time: 2025-10-03T09:00:00-07:00
|
||||
last_edited_time: 2026-03-25T10:35:00-07:00
|
||||
tags:
|
||||
- sales-ops
|
||||
- segmentation
|
||||
- plans
|
||||
related_expected_answers:
|
||||
- enterprise_nrr_q1_vs_q4_breakout
|
||||
- enterprise_expansions_q1_2026
|
||||
related_metric_keys:
|
||||
- segment
|
||||
- net_revenue_retention
|
||||
anchors:
|
||||
- notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
---
|
||||
|
||||
# Sales Ops Segmentation Guide
|
||||
|
||||
Owner: Jordan Lee (sales_ops)
|
||||
|
||||
## Growth Plan Normalization
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
|
||||
The current plan language is starter, growth, and enterprise.
|
||||
|
||||
Raw historical pro_plus values normalize to growth for current artifacts after 2025-10-01.
|
||||
|
||||
Retention cohort membership is evaluated at quarter start unless the golden question states another as-of date.
|
||||
|
||||
Segment membership changes are bridge items and are not silently classified as expansion or churn.
|
||||
|
||||
## Segment Membership
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#segment-membership
|
||||
|
||||
Segment membership changes are bridge items and are not silently classified as expansion or churn.
|
||||
|
||||
## Quarter Start Cohorts
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#quarter-start-cohorts
|
||||
|
||||
Retention cohort membership is evaluated at quarter start unless the golden question states another as-of date.
|
||||
|
||||
## Historical Plan Alias
|
||||
Anchor: notion://notion_page_sales_ops_segmentation#historical-plan-alias
|
||||
|
||||
Raw historical pro_plus values normalize to growth for current artifacts after 2025-10-01.
|
||||
|
||||
## Related Evidence
|
||||
|
||||
- notion://notion_page_sales_ops_segmentation#growth-plan-normalization
|
||||
- expected-answer://enterprise_nrr_q1_vs_q4_breakout
|
||||
- expected-answer://enterprise_expansions_q1_2026
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
page_id: notion_page_support_escalation_runbook
|
||||
title: 'Support Escalation Runbook'
|
||||
owner_person_key: priya_shah
|
||||
owner_team: customer_success
|
||||
owner_notion_user_id: notion_user_0005
|
||||
status: current
|
||||
created_time: 2026-03-06T09:00:00-08:00
|
||||
last_edited_time: 2026-03-21T15:30:00-07:00
|
||||
parent_page_id: notion_page_customer_health_playbook
|
||||
tags:
|
||||
- customer-success
|
||||
- support
|
||||
- risk
|
||||
source_anchors:
|
||||
- notion://notion_page_support_escalation_runbook#triage
|
||||
- looker://dashboard/dash_customer_health_risk
|
||||
- drive://drive_file_customer_health_scorecard_q1#high-risk-accounts
|
||||
- slack://customer-success/2026-03-31/1774976400.000100?thread_ts=1774976400.000100
|
||||
---
|
||||
|
||||
# Support Escalation Runbook
|
||||
|
||||
Owner: Priya Shah (customer_success)
|
||||
|
||||
## Operating Context
|
||||
Anchor: notion://notion_page_support_escalation_runbook#support-escalation-runbook
|
||||
|
||||
High-risk review combines open support tickets with recent requisition and approval activity drops.
|
||||
Use looker://dashboard/dash_customer_health_risk for the list and drive://drive_file_customer_health_scorecard_q1#high-risk-accounts for the scorecard.
|
||||
Escalations are coordinated in slack://customer-success/2026-03-31/1774976400.000100?thread_ts=1774976400.000100.
|
||||
|
||||
## Source Anchors
|
||||
|
||||
- notion://notion_page_support_escalation_runbook#triage
|
||||
- looker://dashboard/dash_customer_health_risk
|
||||
- drive://drive_file_customer_health_scorecard_q1#high-risk-accounts
|
||||
- slack://customer-success/2026-03-31/1774976400.000100?thread_ts=1774976400.000100
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
account_id,parent_account_id,account_name,domain,industry,sales_region,size_band,lifecycle_status,is_internal,is_test,created_at
|
||||
acct_0001,parent_0001,Orbit Customer 001,customer-001.example.com,software,na,enterprise,active,false,false,2025-01-01T00:00:00Z
|
||||
acct_0002,parent_0002,Orbit Customer 002,customer-002.example.com,manufacturing,emea,enterprise,active,false,false,2025-02-02T00:00:00Z
|
||||
acct_0003,parent_0003,Orbit Customer 003,customer-003.example.com,healthcare,apac,enterprise,active,false,false,2025-03-03T00:00:00Z
|
||||
acct_0004,parent_0004,Orbit Customer 004,customer-004.example.com,financial_services,na,enterprise,active,false,false,2025-04-04T00:00:00Z
|
||||
acct_0005,parent_0005,Orbit Customer 005,customer-005.example.com,retail,emea,enterprise,active,false,false,2025-05-05T00:00:00Z
|
||||
acct_0006,parent_0006,Orbit Customer 006,customer-006.example.com,software,apac,enterprise,active,false,false,2025-06-06T00:00:00Z
|
||||
acct_0007,parent_0007,Orbit Customer 007,customer-007.example.com,manufacturing,na,enterprise,active,false,false,2025-07-07T00:00:00Z
|
||||
acct_0008,parent_0008,Orbit Customer 008,customer-008.example.com,healthcare,emea,enterprise,active,false,false,2025-08-08T00:00:00Z
|
||||
acct_0009,parent_0009,Orbit Customer 009,customer-009.example.com,financial_services,apac,enterprise,active,false,false,2025-09-09T00:00:00Z
|
||||
acct_0010,parent_0010,Orbit Customer 010,customer-010.example.com,retail,na,enterprise,active,false,false,2025-10-10T00:00:00Z
|
||||
acct_0011,parent_0011,Orbit Customer 011,customer-011.example.com,software,emea,enterprise,active,false,false,2025-11-11T00:00:00Z
|
||||
acct_0012,parent_0012,Orbit Customer 012,customer-012.example.com,manufacturing,apac,enterprise,active,false,false,2025-12-12T00:00:00Z
|
||||
acct_0013,parent_0013,Orbit Customer 013,customer-013.example.com,healthcare,na,enterprise,active,false,false,2025-01-13T00:00:00Z
|
||||
acct_0014,parent_0014,Orbit Customer 014,customer-014.example.com,financial_services,emea,enterprise,active,false,false,2025-02-14T00:00:00Z
|
||||
acct_0015,parent_0015,Orbit Customer 015,customer-015.example.com,retail,apac,enterprise,active,false,false,2025-03-15T00:00:00Z
|
||||
acct_0016,parent_0016,Orbit Customer 016,customer-016.example.com,software,na,enterprise,active,false,false,2025-04-16T00:00:00Z
|
||||
acct_0017,parent_0017,Orbit Customer 017,customer-017.example.com,manufacturing,emea,enterprise,active,false,false,2025-05-17T00:00:00Z
|
||||
acct_0018,parent_0018,Orbit Customer 018,customer-018.example.com,healthcare,apac,enterprise,active,false,false,2025-06-18T00:00:00Z
|
||||
acct_0019,parent_0019,Orbit Customer 019,customer-019.example.com,financial_services,na,enterprise,active,false,false,2025-07-19T00:00:00Z
|
||||
acct_0020,parent_0020,Orbit Customer 020,customer-020.example.com,retail,emea,enterprise,active,false,false,2025-08-20T00:00:00Z
|
||||
acct_0021,parent_0021,Orbit Customer 021,customer-021.example.com,software,apac,enterprise,active,false,false,2025-09-21T00:00:00Z
|
||||
acct_0022,parent_0022,Orbit Customer 022,customer-022.example.com,manufacturing,na,enterprise,active,false,false,2025-10-22T00:00:00Z
|
||||
acct_0023,parent_0023,Orbit Customer 023,customer-023.example.com,healthcare,emea,enterprise,active,false,false,2025-11-23T00:00:00Z
|
||||
acct_0024,parent_0024,Orbit Customer 024,customer-024.example.com,financial_services,apac,enterprise,active,false,false,2025-12-24T00:00:00Z
|
||||
acct_0025,parent_0025,Orbit Customer 025,customer-025.example.com,retail,na,enterprise,active,false,false,2025-01-25T00:00:00Z
|
||||
acct_0026,parent_0026,Orbit Customer 026,customer-026.example.com,software,emea,enterprise,active,false,false,2025-02-26T00:00:00Z
|
||||
acct_0027,parent_0027,Orbit Customer 027,customer-027.example.com,manufacturing,apac,enterprise,active,false,false,2025-03-27T00:00:00Z
|
||||
acct_0028,parent_0028,Orbit Customer 028,customer-028.example.com,healthcare,na,enterprise,active,false,false,2025-04-28T00:00:00Z
|
||||
acct_0029,parent_0029,Orbit Customer 029,customer-029.example.com,financial_services,emea,enterprise,active,false,false,2025-05-01T00:00:00Z
|
||||
acct_0030,parent_0030,Orbit Customer 030,customer-030.example.com,retail,apac,enterprise,active,false,false,2025-06-02T00:00:00Z
|
||||
acct_0031,parent_0031,Orbit Customer 031,customer-031.example.com,software,na,enterprise,active,false,false,2025-07-03T00:00:00Z
|
||||
acct_0032,parent_0032,Orbit Customer 032,customer-032.example.com,manufacturing,emea,enterprise,active,false,false,2025-08-04T00:00:00Z
|
||||
acct_0033,parent_0033,Orbit Customer 033,customer-033.example.com,healthcare,apac,enterprise,active,false,false,2025-09-05T00:00:00Z
|
||||
acct_0034,parent_0034,Orbit Customer 034,customer-034.example.com,financial_services,na,enterprise,active,false,false,2025-10-06T00:00:00Z
|
||||
acct_0035,parent_0035,Orbit Customer 035,customer-035.example.com,retail,emea,enterprise,active,false,false,2025-11-07T00:00:00Z
|
||||
acct_0036,parent_0036,Orbit Customer 036,customer-036.example.com,software,apac,enterprise,active,false,false,2025-12-08T00:00:00Z
|
||||
acct_0037,parent_0037,Orbit Customer 037,customer-037.example.com,manufacturing,na,enterprise,active,false,false,2025-01-09T00:00:00Z
|
||||
acct_0038,parent_0038,Orbit Customer 038,customer-038.example.com,healthcare,emea,enterprise,active,false,false,2025-02-10T00:00:00Z
|
||||
acct_0039,parent_0039,Orbit Customer 039,customer-039.example.com,financial_services,apac,enterprise,active,false,false,2025-03-11T00:00:00Z
|
||||
acct_0040,parent_0040,Orbit Customer 040,customer-040.example.com,retail,na,enterprise,active,false,false,2025-04-12T00:00:00Z
|
||||
acct_0041,parent_0041,Orbit Customer 041,customer-041.example.com,software,emea,enterprise,active,false,false,2025-05-13T00:00:00Z
|
||||
acct_0042,parent_0042,Orbit Customer 042,customer-042.example.com,manufacturing,apac,enterprise,active,false,false,2025-06-14T00:00:00Z
|
||||
acct_0043,parent_0043,Orbit Customer 043,customer-043.example.com,healthcare,na,enterprise,active,false,false,2025-07-15T00:00:00Z
|
||||
acct_0044,parent_0044,Orbit Customer 044,customer-044.example.com,financial_services,emea,enterprise,active,false,false,2025-08-16T00:00:00Z
|
||||
acct_0045,parent_0045,Orbit Customer 045,customer-045.example.com,retail,apac,enterprise,active,false,false,2025-09-17T00:00:00Z
|
||||
acct_0046,parent_0046,Orbit Customer 046,customer-046.example.com,software,na,enterprise,active,false,false,2025-10-18T00:00:00Z
|
||||
acct_0047,parent_0047,Orbit Customer 047,customer-047.example.com,manufacturing,emea,enterprise,active,false,false,2025-11-19T00:00:00Z
|
||||
acct_0048,parent_0048,Orbit Customer 048,customer-048.example.com,healthcare,apac,enterprise,active,false,false,2025-12-20T00:00:00Z
|
||||
acct_0049,parent_0049,Orbit Customer 049,customer-049.example.com,financial_services,na,enterprise,active,false,false,2025-01-21T00:00:00Z
|
||||
acct_0050,parent_0050,Orbit Customer 050,customer-050.example.com,retail,emea,enterprise,active,false,false,2025-02-22T00:00:00Z
|
||||
acct_0051,parent_0051,Orbit Customer 051,customer-051.example.com,software,apac,enterprise,active,false,false,2025-03-23T00:00:00Z
|
||||
acct_0052,parent_0052,Orbit Customer 052,customer-052.example.com,manufacturing,na,enterprise,active,false,false,2025-04-24T00:00:00Z
|
||||
acct_0053,parent_0053,Orbit Customer 053,customer-053.example.com,healthcare,emea,enterprise,active,false,false,2025-05-25T00:00:00Z
|
||||
acct_0054,parent_0054,Orbit Customer 054,customer-054.example.com,financial_services,apac,enterprise,active,false,false,2025-06-26T00:00:00Z
|
||||
acct_0055,parent_0055,Orbit Customer 055,customer-055.example.com,retail,na,enterprise,active,false,false,2025-07-27T00:00:00Z
|
||||
acct_0056,parent_0056,Orbit Customer 056,customer-056.example.com,software,emea,enterprise,active,false,false,2025-08-28T00:00:00Z
|
||||
acct_0057,parent_0057,Orbit Customer 057,customer-057.example.com,manufacturing,apac,enterprise,active,false,false,2025-09-01T00:00:00Z
|
||||
acct_0058,parent_0058,Orbit Customer 058,customer-058.example.com,healthcare,na,enterprise,active,false,false,2025-10-02T00:00:00Z
|
||||
acct_0059,parent_0059,Orbit Customer 059,customer-059.example.com,financial_services,emea,enterprise,active,false,false,2025-11-03T00:00:00Z
|
||||
acct_0060,parent_0060,Orbit Customer 060,customer-060.example.com,retail,apac,enterprise,active,false,false,2025-12-04T00:00:00Z
|
||||
acct_0061,parent_0061,Orbit Customer 061,customer-061.example.com,software,na,enterprise,active,false,false,2025-01-05T00:00:00Z
|
||||
acct_0062,parent_0062,Orbit Customer 062,customer-062.example.com,manufacturing,emea,enterprise,active,false,false,2025-02-06T00:00:00Z
|
||||
acct_0063,parent_0063,Orbit Customer 063,customer-063.example.com,healthcare,apac,enterprise,active,false,false,2025-03-07T00:00:00Z
|
||||
acct_0064,parent_0064,Orbit Customer 064,customer-064.example.com,financial_services,na,enterprise,active,false,false,2025-04-08T00:00:00Z
|
||||
acct_0065,parent_0065,Orbit Customer 065,customer-065.example.com,retail,emea,enterprise,active,false,false,2025-05-09T00:00:00Z
|
||||
acct_0066,parent_0066,Orbit Customer 066,customer-066.example.com,software,apac,enterprise,active,false,false,2025-06-10T00:00:00Z
|
||||
acct_0067,parent_0067,Orbit Customer 067,customer-067.example.com,manufacturing,na,enterprise,active,false,false,2025-07-11T00:00:00Z
|
||||
acct_0068,parent_0068,Orbit Customer 068,customer-068.example.com,healthcare,emea,enterprise,active,false,false,2025-08-12T00:00:00Z
|
||||
acct_0069,parent_0069,Orbit Customer 069,customer-069.example.com,financial_services,apac,enterprise,active,false,false,2025-09-13T00:00:00Z
|
||||
acct_0070,parent_0070,Orbit Customer 070,customer-070.example.com,retail,na,enterprise,active,false,false,2025-10-14T00:00:00Z
|
||||
acct_0071,parent_0071,Orbit Customer 071,customer-071.example.com,software,emea,enterprise,active,false,false,2025-11-15T00:00:00Z
|
||||
acct_0072,parent_0072,Orbit Customer 072,customer-072.example.com,manufacturing,apac,enterprise,active,false,false,2025-12-16T00:00:00Z
|
||||
acct_0073,parent_0073,Orbit Customer 073,customer-073.example.com,healthcare,na,enterprise,active,false,false,2025-01-17T00:00:00Z
|
||||
acct_0074,parent_0074,Orbit Customer 074,customer-074.example.com,financial_services,emea,enterprise,active,false,false,2025-02-18T00:00:00Z
|
||||
acct_0075,parent_0075,Orbit Customer 075,customer-075.example.com,retail,apac,enterprise,active,false,false,2025-03-19T00:00:00Z
|
||||
acct_0076,parent_0076,Orbit Customer 076,customer-076.example.com,software,na,enterprise,active,false,false,2025-04-20T00:00:00Z
|
||||
acct_0077,parent_0077,Orbit Customer 077,customer-077.example.com,manufacturing,emea,enterprise,active,false,false,2025-05-21T00:00:00Z
|
||||
acct_0078,parent_0078,Orbit Customer 078,customer-078.example.com,healthcare,apac,enterprise,active,false,false,2025-06-22T00:00:00Z
|
||||
acct_0079,parent_0079,Orbit Customer 079,customer-079.example.com,financial_services,na,enterprise,active,false,false,2025-07-23T00:00:00Z
|
||||
acct_0080,parent_0080,Orbit Customer 080,customer-080.example.com,retail,emea,enterprise,active,false,false,2025-08-24T00:00:00Z
|
||||
acct_0081,parent_0081,Orbit Customer 081,customer-081.example.com,software,apac,mid_market,active,false,false,2025-09-25T00:00:00Z
|
||||
acct_0082,parent_0082,Orbit Customer 082,customer-082.example.com,manufacturing,na,mid_market,active,false,false,2025-10-26T00:00:00Z
|
||||
acct_0083,parent_0083,Orbit Customer 083,customer-083.example.com,healthcare,emea,mid_market,active,false,false,2025-11-27T00:00:00Z
|
||||
acct_0084,parent_0084,Orbit Customer 084,customer-084.example.com,financial_services,apac,mid_market,active,false,false,2025-12-28T00:00:00Z
|
||||
acct_0085,parent_0085,Orbit Customer 085,customer-085.example.com,retail,na,mid_market,active,false,false,2025-01-01T00:00:00Z
|
||||
acct_0086,parent_0086,Orbit Customer 086,customer-086.example.com,software,emea,mid_market,active,false,false,2025-02-02T00:00:00Z
|
||||
acct_0087,parent_0087,Orbit Customer 087,customer-087.example.com,manufacturing,apac,mid_market,active,false,false,2025-03-03T00:00:00Z
|
||||
acct_0088,parent_0088,Orbit Customer 088,customer-088.example.com,healthcare,na,mid_market,active,false,false,2025-04-04T00:00:00Z
|
||||
acct_0089,parent_0089,Orbit Customer 089,customer-089.example.com,financial_services,emea,mid_market,active,false,false,2025-05-05T00:00:00Z
|
||||
acct_0090,parent_0090,Orbit Customer 090,customer-090.example.com,retail,apac,mid_market,active,false,false,2025-06-06T00:00:00Z
|
||||
acct_0091,parent_0091,Orbit Customer 091,customer-091.example.com,software,na,mid_market,active,false,false,2025-07-07T00:00:00Z
|
||||
acct_0092,parent_0092,Orbit Customer 092,customer-092.example.com,manufacturing,emea,mid_market,active,false,false,2025-08-08T00:00:00Z
|
||||
acct_0093,parent_0093,Orbit Customer 093,customer-093.example.com,healthcare,apac,mid_market,active,false,false,2025-09-09T00:00:00Z
|
||||
acct_0094,parent_0094,Orbit Customer 094,customer-094.example.com,financial_services,na,mid_market,active,false,false,2025-10-10T00:00:00Z
|
||||
acct_0095,parent_0095,Orbit Customer 095,customer-095.example.com,retail,emea,mid_market,active,false,false,2025-11-11T00:00:00Z
|
||||
acct_0096,parent_0096,Orbit Customer 096,customer-096.example.com,software,apac,mid_market,active,false,false,2025-12-12T00:00:00Z
|
||||
acct_0097,parent_0097,Orbit Customer 097,customer-097.example.com,manufacturing,na,mid_market,active,false,false,2025-01-13T00:00:00Z
|
||||
acct_0098,parent_0098,Orbit Customer 098,customer-098.example.com,healthcare,emea,mid_market,active,false,false,2025-02-14T00:00:00Z
|
||||
acct_0099,parent_0099,Orbit Customer 099,customer-099.example.com,financial_services,apac,mid_market,active,false,false,2025-03-15T00:00:00Z
|
||||
acct_0100,parent_0100,Orbit Customer 100,customer-100.example.com,retail,na,mid_market,active,false,false,2025-04-16T00:00:00Z
|
||||
acct_0101,parent_0101,Orbit Customer 101,customer-101.example.com,software,emea,mid_market,active,false,false,2025-05-17T00:00:00Z
|
||||
acct_0102,parent_0102,Orbit Customer 102,customer-102.example.com,manufacturing,apac,mid_market,active,false,false,2025-06-18T00:00:00Z
|
||||
acct_0103,parent_0103,Orbit Customer 103,customer-103.example.com,healthcare,na,mid_market,active,false,false,2025-07-19T00:00:00Z
|
||||
acct_0104,parent_0104,Orbit Customer 104,customer-104.example.com,financial_services,emea,mid_market,active,false,false,2025-08-20T00:00:00Z
|
||||
acct_0105,parent_0105,Orbit Customer 105,customer-105.example.com,retail,apac,mid_market,active,false,false,2025-09-21T00:00:00Z
|
||||
acct_0106,parent_0106,Orbit Customer 106,customer-106.example.com,software,na,mid_market,active,false,false,2025-10-22T00:00:00Z
|
||||
acct_0107,parent_0107,Orbit Customer 107,customer-107.example.com,manufacturing,emea,mid_market,active,false,false,2025-11-23T00:00:00Z
|
||||
acct_0108,parent_0108,Orbit Customer 108,customer-108.example.com,healthcare,apac,mid_market,active,false,false,2025-12-24T00:00:00Z
|
||||
acct_0109,parent_0109,Orbit Customer 109,customer-109.example.com,financial_services,na,mid_market,active,false,false,2025-01-25T00:00:00Z
|
||||
acct_0110,parent_0110,Orbit Customer 110,customer-110.example.com,retail,emea,mid_market,active,false,false,2025-02-26T00:00:00Z
|
||||
acct_0111,parent_0111,Orbit Customer 111,customer-111.example.com,software,apac,mid_market,active,false,false,2025-03-27T00:00:00Z
|
||||
acct_0112,parent_0112,Orbit Customer 112,customer-112.example.com,manufacturing,na,mid_market,active,false,false,2025-04-28T00:00:00Z
|
||||
acct_0113,parent_0113,Orbit Customer 113,customer-113.example.com,healthcare,emea,mid_market,active,false,false,2025-05-01T00:00:00Z
|
||||
acct_0114,parent_0114,Orbit Customer 114,customer-114.example.com,financial_services,apac,mid_market,active,false,false,2025-06-02T00:00:00Z
|
||||
acct_0115,parent_0115,Orbit Customer 115,customer-115.example.com,retail,na,mid_market,active,false,false,2025-07-03T00:00:00Z
|
||||
acct_0116,parent_0116,Orbit Customer 116,customer-116.example.com,software,emea,mid_market,active,false,false,2025-08-04T00:00:00Z
|
||||
acct_0117,parent_0117,Orbit Customer 117,customer-117.example.com,manufacturing,apac,mid_market,active,false,false,2025-09-05T00:00:00Z
|
||||
acct_0118,parent_0118,Orbit Customer 118,customer-118.example.com,healthcare,na,mid_market,active,false,false,2025-10-06T00:00:00Z
|
||||
acct_0119,parent_0119,Orbit Customer 119,customer-119.example.com,financial_services,emea,mid_market,active,false,false,2025-11-07T00:00:00Z
|
||||
acct_0120,parent_0120,Orbit Customer 120,customer-120.example.com,retail,apac,mid_market,active,false,false,2025-12-08T00:00:00Z
|
||||
acct_0121,parent_0121,Orbit Customer 121,customer-121.example.com,software,na,mid_market,active,false,false,2025-01-09T00:00:00Z
|
||||
acct_0122,parent_0122,Orbit Customer 122,customer-122.example.com,manufacturing,emea,mid_market,active,false,false,2025-02-10T00:00:00Z
|
||||
acct_0123,parent_0123,Orbit Customer 123,customer-123.example.com,healthcare,apac,mid_market,active,false,false,2025-03-11T00:00:00Z
|
||||
acct_0124,parent_0124,Orbit Customer 124,customer-124.example.com,financial_services,na,mid_market,active,false,false,2025-04-12T00:00:00Z
|
||||
acct_0125,parent_0125,Orbit Customer 125,customer-125.example.com,retail,emea,mid_market,active,false,false,2025-05-13T00:00:00Z
|
||||
acct_0126,parent_0126,Orbit Customer 126,customer-126.example.com,software,apac,mid_market,active,false,false,2025-06-14T00:00:00Z
|
||||
acct_0127,parent_0127,Orbit Customer 127,customer-127.example.com,manufacturing,na,mid_market,active,false,false,2025-07-15T00:00:00Z
|
||||
acct_0128,parent_0128,Orbit Customer 128,customer-128.example.com,healthcare,emea,mid_market,active,false,false,2025-08-16T00:00:00Z
|
||||
acct_0129,parent_0129,Orbit Customer 129,customer-129.example.com,financial_services,apac,mid_market,active,false,false,2025-09-17T00:00:00Z
|
||||
acct_0130,parent_0130,Orbit Customer 130,customer-130.example.com,retail,na,mid_market,active,false,false,2025-10-18T00:00:00Z
|
||||
acct_0131,parent_0131,Orbit Customer 131,customer-131.example.com,software,emea,mid_market,active,false,false,2025-11-19T00:00:00Z
|
||||
acct_0132,parent_0132,Orbit Customer 132,customer-132.example.com,manufacturing,apac,mid_market,active,false,false,2025-12-20T00:00:00Z
|
||||
acct_0133,parent_0001,Orbit Customer 133,customer-133.example.com,healthcare,na,mid_market,active,false,false,2025-01-21T00:00:00Z
|
||||
acct_0134,parent_0002,Orbit Customer 134,customer-134.example.com,financial_services,emea,mid_market,active,false,false,2025-02-22T00:00:00Z
|
||||
acct_0135,parent_0003,Orbit Customer 135,customer-135.example.com,retail,apac,mid_market,active,false,false,2025-03-23T00:00:00Z
|
||||
acct_0136,parent_0004,Orbit Customer 136,customer-136.example.com,software,na,mid_market,active,false,false,2025-04-24T00:00:00Z
|
||||
acct_0137,parent_0005,Orbit Customer 137,customer-137.example.com,manufacturing,emea,mid_market,active,false,false,2025-05-25T00:00:00Z
|
||||
acct_0138,parent_0006,Orbit Customer 138,customer-138.example.com,healthcare,apac,mid_market,active,false,false,2025-06-26T00:00:00Z
|
||||
acct_0139,parent_0007,Orbit Customer 139,customer-139.example.com,financial_services,na,mid_market,active,false,false,2025-07-27T00:00:00Z
|
||||
acct_0140,parent_0008,Orbit Customer 140,customer-140.example.com,retail,emea,mid_market,active,false,false,2025-08-28T00:00:00Z
|
||||
acct_0141,parent_0009,Orbit Customer 141,customer-141.example.com,software,apac,mid_market,active,false,false,2025-09-01T00:00:00Z
|
||||
acct_0142,parent_0010,Orbit Customer 142,customer-142.example.com,manufacturing,na,mid_market,active,false,false,2025-10-02T00:00:00Z
|
||||
acct_0143,parent_0011,Orbit Customer 143,customer-143.example.com,healthcare,emea,mid_market,active,false,false,2025-11-03T00:00:00Z
|
||||
acct_0144,parent_0012,Orbit Customer 144,customer-144.example.com,financial_services,apac,mid_market,active,false,false,2025-12-04T00:00:00Z
|
||||
acct_0145,parent_0013,Orbit Customer 145,customer-145.example.com,retail,na,mid_market,active,false,false,2025-01-05T00:00:00Z
|
||||
acct_0146,parent_0014,Orbit Customer 146,customer-146.example.com,software,emea,mid_market,active,false,false,2025-02-06T00:00:00Z
|
||||
acct_0147,parent_0015,Orbit Customer 147,customer-147.example.com,manufacturing,apac,mid_market,active,false,false,2025-03-07T00:00:00Z
|
||||
acct_0148,parent_0016,Orbit Customer 148,customer-148.example.com,healthcare,na,mid_market,active,false,false,2025-04-08T00:00:00Z
|
||||
acct_0149,parent_0017,Orbit Customer 149,customer-149.example.com,financial_services,emea,mid_market,active,false,false,2025-05-09T00:00:00Z
|
||||
acct_0150,parent_0018,Orbit Customer 150,customer-150.example.com,retail,apac,mid_market,active,false,false,2025-06-10T00:00:00Z
|
||||
acct_0151,parent_0019,Orbit Customer 151,customer-151.example.com,software,na,smb,active,false,false,2025-07-11T00:00:00Z
|
||||
acct_0152,parent_0020,Orbit Customer 152,customer-152.example.com,manufacturing,emea,smb,active,false,false,2025-08-12T00:00:00Z
|
||||
acct_0153,parent_0021,Orbit Customer 153,customer-153.example.com,healthcare,apac,smb,active,false,false,2025-09-13T00:00:00Z
|
||||
acct_0154,parent_0022,Orbit Customer 154,customer-154.example.com,financial_services,na,smb,active,false,false,2025-10-14T00:00:00Z
|
||||
acct_0155,parent_0023,Orbit Customer 155,customer-155.example.com,retail,emea,smb,active,false,false,2025-11-15T00:00:00Z
|
||||
acct_0156,parent_0024,Orbit Customer 156,customer-156.example.com,software,apac,smb,active,false,false,2025-12-16T00:00:00Z
|
||||
acct_0157,parent_0025,Orbit Customer 157,customer-157.example.com,manufacturing,na,smb,active,false,false,2025-01-17T00:00:00Z
|
||||
acct_0158,parent_0026,Orbit Customer 158,customer-158.example.com,healthcare,emea,smb,active,false,false,2025-02-18T00:00:00Z
|
||||
acct_0159,parent_0027,Orbit Customer 159,customer-159.example.com,financial_services,apac,smb,active,false,false,2025-03-19T00:00:00Z
|
||||
acct_0160,parent_0028,Orbit Customer 160,customer-160.example.com,retail,na,smb,active,false,false,2025-04-20T00:00:00Z
|
||||
acct_0161,parent_0029,Orbit Customer 161,customer-161.example.com,software,emea,smb,active,false,false,2025-05-21T00:00:00Z
|
||||
acct_0162,parent_0030,Orbit Customer 162,customer-162.example.com,manufacturing,apac,smb,active,false,false,2025-06-22T00:00:00Z
|
||||
acct_0163,parent_0031,Orbit Customer 163,customer-163.example.com,healthcare,na,smb,active,false,false,2025-07-23T00:00:00Z
|
||||
acct_0164,parent_0032,Orbit Customer 164,customer-164.example.com,financial_services,emea,smb,active,false,false,2025-08-24T00:00:00Z
|
||||
acct_0165,parent_0033,Orbit Customer 165,customer-165.example.com,retail,apac,smb,active,false,false,2025-09-25T00:00:00Z
|
||||
acct_0166,parent_0034,Orbit Customer 166,customer-166.example.com,software,na,smb,active,false,false,2025-10-26T00:00:00Z
|
||||
acct_0167,parent_0035,Orbit Customer 167,customer-167.example.com,manufacturing,emea,smb,active,false,false,2025-11-27T00:00:00Z
|
||||
acct_0168,parent_0036,Orbit Customer 168,customer-168.example.com,healthcare,apac,smb,active,false,false,2025-12-28T00:00:00Z
|
||||
acct_0169,parent_0037,Orbit Customer 169,customer-169.example.com,financial_services,na,smb,active,false,false,2025-01-01T00:00:00Z
|
||||
acct_0170,parent_0038,Orbit Customer 170,customer-170.example.com,retail,emea,smb,active,false,false,2025-02-02T00:00:00Z
|
||||
acct_0171,parent_0039,Orbit Customer 171,customer-171.example.com,software,apac,smb,active,false,false,2025-03-03T00:00:00Z
|
||||
acct_0172,parent_0040,Orbit Customer 172,customer-172.example.com,manufacturing,na,smb,active,false,false,2025-04-04T00:00:00Z
|
||||
acct_0173,parent_0041,Orbit Customer 173,customer-173.example.com,healthcare,emea,smb,active,false,false,2025-05-05T00:00:00Z
|
||||
acct_0174,parent_0042,Orbit Customer 174,customer-174.example.com,financial_services,apac,smb,active,false,false,2025-06-06T00:00:00Z
|
||||
acct_0175,parent_0043,Orbit Customer 175,customer-175.example.com,retail,na,smb,active,false,false,2025-07-07T00:00:00Z
|
||||
acct_0176,parent_0044,Orbit Customer 176,customer-176.example.com,software,emea,smb,active,false,false,2025-08-08T00:00:00Z
|
||||
acct_0177,parent_0045,Orbit Customer 177,customer-177.example.com,manufacturing,apac,smb,active,false,false,2025-09-09T00:00:00Z
|
||||
acct_0178,parent_0046,Orbit Customer 178,customer-178.example.com,healthcare,na,smb,active,false,false,2025-10-10T00:00:00Z
|
||||
acct_0179,parent_0047,Orbit Customer 179,customer-179.example.com,financial_services,emea,smb,active,false,false,2025-11-11T00:00:00Z
|
||||
acct_0180,parent_0048,Orbit Customer 180,customer-180.example.com,retail,apac,smb,active,false,false,2025-12-12T00:00:00Z
|
||||
acct_0181,parent_0049,Orbit Customer 181,customer-181.example.com,software,na,smb,active,false,false,2025-01-13T00:00:00Z
|
||||
acct_0182,parent_0050,Orbit Customer 182,customer-182.example.com,manufacturing,emea,smb,active,false,false,2025-02-14T00:00:00Z
|
||||
acct_0183,parent_0051,Orbit Customer 183,customer-183.example.com,healthcare,apac,smb,active,false,false,2025-03-15T00:00:00Z
|
||||
acct_0184,parent_0052,Orbit Customer 184,customer-184.example.com,financial_services,na,smb,active,false,false,2025-04-16T00:00:00Z
|
||||
acct_0185,parent_0053,Orbit Customer 185,customer-185.example.com,retail,emea,smb,active,false,false,2025-05-17T00:00:00Z
|
||||
acct_0186,parent_0054,Orbit Customer 186,customer-186.example.com,software,apac,smb,active,false,false,2025-06-18T00:00:00Z
|
||||
acct_0187,parent_0055,Orbit Customer 187,customer-187.example.com,manufacturing,na,smb,active,false,false,2025-07-19T00:00:00Z
|
||||
acct_0188,parent_0056,Orbit Customer 188,customer-188.example.com,healthcare,emea,smb,active,false,false,2025-08-20T00:00:00Z
|
||||
acct_0189,parent_0057,Orbit Customer 189,customer-189.example.com,financial_services,apac,smb,active,false,false,2025-09-21T00:00:00Z
|
||||
acct_0190,parent_0058,Orbit Customer 190,customer-190.example.com,retail,na,smb,active,false,false,2025-10-22T00:00:00Z
|
||||
acct_0191,parent_0059,Orbit Customer 191,customer-191.example.com,software,emea,smb,active,false,false,2025-11-23T00:00:00Z
|
||||
acct_0192,parent_0060,Orbit Customer 192,customer-192.example.com,manufacturing,apac,smb,active,false,false,2025-12-24T00:00:00Z
|
||||
acct_0193,parent_0061,Orbit Customer 193,customer-193.example.com,healthcare,na,smb,active,false,false,2025-01-25T00:00:00Z
|
||||
acct_0194,parent_0062,Orbit Customer 194,customer-194.example.com,financial_services,emea,smb,active,false,false,2025-02-26T00:00:00Z
|
||||
acct_0195,parent_0063,Orbit Customer 195,customer-195.example.com,retail,apac,smb,active,false,false,2025-03-27T00:00:00Z
|
||||
acct_0196,parent_0064,Orbit Customer 196,customer-196.example.com,software,na,smb,active,false,false,2025-04-28T00:00:00Z
|
||||
acct_0197,parent_0065,Orbit Customer 197,customer-197.example.com,manufacturing,emea,smb,active,false,false,2025-05-01T00:00:00Z
|
||||
acct_0198,parent_0066,Orbit Customer 198,customer-198.example.com,healthcare,apac,smb,active,false,false,2025-06-02T00:00:00Z
|
||||
acct_0199,parent_0067,Orbit Customer 199,customer-199.example.com,financial_services,na,smb,churned,false,false,2025-07-03T00:00:00Z
|
||||
acct_0200,parent_0068,Orbit Customer 200,customer-200.example.com,retail,emea,smb,churned,false,false,2025-08-04T00:00:00Z
|
||||
acct_0201,parent_0069,Orbit Customer 201,customer-201.example.com,software,apac,smb,internal,true,false,2025-09-05T00:00:00Z
|
||||
acct_0202,parent_0070,Orbit Customer 202,customer-202.example.com,manufacturing,na,smb,internal,true,false,2025-10-06T00:00:00Z
|
||||
acct_0203,parent_0071,Orbit Customer 203,customer-203.example.com,healthcare,emea,smb,internal,true,false,2025-11-07T00:00:00Z
|
||||
acct_0204,parent_0072,Orbit Customer 204,customer-204.example.com,financial_services,apac,smb,internal,true,false,2025-12-08T00:00:00Z
|
||||
acct_0205,parent_0073,Orbit Customer 205,customer-205.example.com,retail,na,smb,internal,true,false,2025-01-09T00:00:00Z
|
||||
acct_0206,parent_0074,Orbit Customer 206,customer-206.example.com,software,emea,smb,test,false,true,2025-02-10T00:00:00Z
|
||||
acct_0207,parent_0075,Orbit Customer 207,customer-207.example.com,manufacturing,apac,smb,test,false,true,2025-03-11T00:00:00Z
|
||||
acct_0208,parent_0076,Orbit Customer 208,customer-208.example.com,healthcare,na,smb,test,false,true,2025-04-12T00:00:00Z
|
||||
acct_0209,parent_0077,Orbit Customer 209,customer-209.example.com,financial_services,emea,smb,test,false,true,2025-05-13T00:00:00Z
|
||||
acct_0210,parent_0078,Orbit Customer 210,customer-210.example.com,retail,apac,smb,test,false,true,2025-06-14T00:00:00Z
|
||||
|
|
|
@ -0,0 +1,721 @@
|
|||
arr_movement_id,account_id,parent_account_id,contract_id,movement_date,movement_type,movement_reason,arr_delta_cents,starting_arr_cents,ending_arr_cents
|
||||
arr_move_0001,acct_0001,parent_0001,contract_0001,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0002,acct_0002,parent_0002,contract_0002,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0003,acct_0003,parent_0003,contract_0003,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0004,acct_0004,parent_0004,contract_0004,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0005,acct_0005,parent_0005,contract_0005,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0006,acct_0006,parent_0006,contract_0006,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0007,acct_0007,parent_0007,contract_0007,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0008,acct_0008,parent_0008,contract_0008,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0009,acct_0009,parent_0009,contract_0009,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0010,acct_0010,parent_0010,contract_0010,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0011,acct_0011,parent_0011,contract_0011,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0012,acct_0012,parent_0012,contract_0012,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0013,acct_0013,parent_0013,contract_0013,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0014,acct_0014,parent_0014,contract_0014,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0015,acct_0015,parent_0015,contract_0015,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0016,acct_0016,parent_0016,contract_0016,2026-02-15,expansion,seat_growth,4500000,30000000,34500000
|
||||
arr_move_0017,acct_0017,parent_0017,contract_0017,2026-02-15,expansion,seat_growth,6000000,70000000,76000000
|
||||
arr_move_0018,acct_0018,parent_0018,contract_0018,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0019,acct_0019,parent_0019,contract_0019,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0020,acct_0020,parent_0020,contract_0020,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0021,acct_0021,parent_0021,contract_0021,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0022,acct_0022,parent_0022,contract_0022,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0023,acct_0023,parent_0023,contract_0023,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0024,acct_0024,parent_0024,contract_0024,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0025,acct_0025,parent_0025,contract_0025,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0026,acct_0026,parent_0026,contract_0026,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0027,acct_0027,parent_0027,contract_0027,2026-02-20,contraction,discount_expiration,-4500000,50000000,45500000
|
||||
arr_move_0028,acct_0028,parent_0028,contract_0028,2026-02-20,contraction,discount_expiration,-5500000,100000000,94500000
|
||||
arr_move_0029,acct_0029,parent_0029,contract_0029,2026-03-10,churn,budget_loss,-5000000,100000000,95000000
|
||||
arr_move_0030,acct_0030,parent_0030,contract_0030,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0031,acct_0031,parent_0031,contract_0031,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0032,acct_0032,parent_0032,contract_0032,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0033,acct_0033,parent_0033,contract_0033,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0034,acct_0034,parent_0034,contract_0034,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0035,acct_0035,parent_0035,contract_0035,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0036,acct_0036,parent_0036,contract_0036,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0037,acct_0037,parent_0037,contract_0037,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0038,acct_0038,parent_0038,contract_0038,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0039,acct_0039,parent_0039,contract_0039,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0040,acct_0040,parent_0040,contract_0040,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0041,acct_0041,parent_0041,contract_0041,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0042,acct_0042,parent_0042,contract_0042,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0043,acct_0043,parent_0043,contract_0043,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0044,acct_0044,parent_0044,contract_0044,2025-11-15,expansion,seat_growth,5000000,50000000,55000000
|
||||
arr_move_0045,acct_0045,parent_0045,contract_0045,2025-11-15,expansion,seat_growth,11800000,50000000,61800000
|
||||
arr_move_0046,acct_0046,parent_0046,contract_0046,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0047,acct_0047,parent_0047,contract_0047,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0048,acct_0048,parent_0048,contract_0048,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0049,acct_0049,parent_0049,contract_0049,2025-11-20,contraction,scope_reduction,-2500000,100000000,97500000
|
||||
arr_move_0050,acct_0090,parent_0090,contract_0101,2025-06-15,new,generated_history,360000,3200000,3560000
|
||||
arr_move_0051,acct_0091,parent_0091,contract_0102,2025-06-15,expansion,generated_history,370000,3300000,3670000
|
||||
arr_move_0052,acct_0092,parent_0092,contract_0103,2025-06-15,contraction,generated_history,-250000,3400000,3150000
|
||||
arr_move_0053,acct_0093,parent_0093,contract_0104,2025-06-15,reactivation,generated_history,260000,3500000,3760000
|
||||
arr_move_0054,acct_0094,parent_0094,contract_0105,2025-06-15,new,generated_history,270000,3600000,3870000
|
||||
arr_move_0055,acct_0095,parent_0095,contract_0106,2025-06-15,expansion,generated_history,280000,3700000,3980000
|
||||
arr_move_0056,acct_0096,parent_0096,contract_0107,2025-06-15,contraction,generated_history,-290000,3800000,3510000
|
||||
arr_move_0057,acct_0097,parent_0097,contract_0108,2025-06-15,reactivation,generated_history,300000,2000000,2300000
|
||||
arr_move_0058,acct_0098,parent_0098,contract_0109,2025-06-15,new,generated_history,310000,2100000,2410000
|
||||
arr_move_0059,acct_0099,parent_0099,contract_0110,2025-06-15,expansion,generated_history,320000,2200000,2520000
|
||||
arr_move_0060,acct_0100,parent_0100,contract_0111,2025-06-15,contraction,generated_history,-330000,2300000,1970000
|
||||
arr_move_0061,acct_0101,parent_0101,contract_0112,2025-06-15,reactivation,generated_history,340000,2400000,2740000
|
||||
arr_move_0062,acct_0102,parent_0102,contract_0113,2025-06-15,new,generated_history,350000,2500000,2850000
|
||||
arr_move_0063,acct_0103,parent_0103,contract_0114,2025-06-15,expansion,generated_history,360000,2600000,2960000
|
||||
arr_move_0064,acct_0104,parent_0104,contract_0115,2025-06-15,contraction,generated_history,-370000,2700000,2330000
|
||||
arr_move_0065,acct_0105,parent_0105,contract_0116,2025-06-15,reactivation,generated_history,250000,2800000,3050000
|
||||
arr_move_0066,acct_0106,parent_0106,contract_0117,2025-06-15,new,generated_history,260000,2900000,3160000
|
||||
arr_move_0067,acct_0107,parent_0107,contract_0118,2025-06-15,expansion,generated_history,270000,3000000,3270000
|
||||
arr_move_0068,acct_0108,parent_0108,contract_0119,2025-06-15,contraction,generated_history,-280000,3100000,2820000
|
||||
arr_move_0069,acct_0109,parent_0109,contract_0120,2025-06-15,reactivation,generated_history,290000,3200000,3490000
|
||||
arr_move_0070,acct_0110,parent_0110,contract_0121,2025-06-15,new,generated_history,300000,3300000,3600000
|
||||
arr_move_0071,acct_0111,parent_0111,contract_0122,2025-06-15,expansion,generated_history,310000,3400000,3710000
|
||||
arr_move_0072,acct_0112,parent_0112,contract_0123,2025-06-15,contraction,generated_history,-320000,3500000,3180000
|
||||
arr_move_0073,acct_0113,parent_0113,contract_0124,2025-06-15,reactivation,generated_history,330000,3600000,3930000
|
||||
arr_move_0074,acct_0114,parent_0114,contract_0125,2025-06-15,new,generated_history,340000,3700000,4040000
|
||||
arr_move_0075,acct_0115,parent_0115,contract_0126,2025-06-15,expansion,generated_history,350000,3800000,4150000
|
||||
arr_move_0076,acct_0116,parent_0116,contract_0127,2025-06-15,contraction,generated_history,-360000,2000000,1640000
|
||||
arr_move_0077,acct_0117,parent_0117,contract_0128,2025-06-15,reactivation,generated_history,370000,2100000,2470000
|
||||
arr_move_0078,acct_0118,parent_0118,contract_0129,2025-06-15,new,generated_history,250000,2200000,2450000
|
||||
arr_move_0079,acct_0119,parent_0119,contract_0130,2025-06-15,expansion,generated_history,260000,2300000,2560000
|
||||
arr_move_0080,acct_0120,parent_0120,contract_0131,2025-06-15,contraction,generated_history,-270000,2400000,2130000
|
||||
arr_move_0081,acct_0121,parent_0121,contract_0132,2025-06-15,reactivation,generated_history,280000,2500000,2780000
|
||||
arr_move_0082,acct_0122,parent_0122,contract_0133,2025-06-15,new,generated_history,290000,2600000,2890000
|
||||
arr_move_0083,acct_0123,parent_0123,contract_0134,2025-06-15,expansion,generated_history,300000,2700000,3000000
|
||||
arr_move_0084,acct_0124,parent_0124,contract_0135,2025-06-15,contraction,generated_history,-310000,2800000,2490000
|
||||
arr_move_0085,acct_0125,parent_0125,contract_0136,2025-06-15,reactivation,generated_history,320000,2900000,3220000
|
||||
arr_move_0086,acct_0126,parent_0126,contract_0137,2025-06-15,new,generated_history,330000,3000000,3330000
|
||||
arr_move_0087,acct_0127,parent_0127,contract_0138,2025-06-15,expansion,generated_history,340000,3100000,3440000
|
||||
arr_move_0088,acct_0128,parent_0128,contract_0139,2025-06-15,contraction,generated_history,-350000,3200000,2850000
|
||||
arr_move_0089,acct_0129,parent_0129,contract_0140,2025-06-15,reactivation,generated_history,360000,3300000,3660000
|
||||
arr_move_0090,acct_0130,parent_0130,contract_0141,2025-06-15,new,generated_history,370000,3400000,3770000
|
||||
arr_move_0091,acct_0131,parent_0131,contract_0142,2025-06-15,expansion,generated_history,250000,3500000,3750000
|
||||
arr_move_0092,acct_0132,parent_0132,contract_0143,2025-06-15,contraction,generated_history,-260000,3600000,3340000
|
||||
arr_move_0093,acct_0133,parent_0001,contract_0144,2025-06-15,reactivation,generated_history,270000,3700000,3970000
|
||||
arr_move_0094,acct_0134,parent_0002,contract_0145,2025-06-15,new,generated_history,280000,3800000,4080000
|
||||
arr_move_0095,acct_0135,parent_0003,contract_0146,2025-06-15,expansion,generated_history,290000,2000000,2290000
|
||||
arr_move_0096,acct_0136,parent_0004,contract_0147,2025-06-15,contraction,generated_history,-300000,2100000,1800000
|
||||
arr_move_0097,acct_0137,parent_0005,contract_0148,2025-06-15,reactivation,generated_history,310000,2200000,2510000
|
||||
arr_move_0098,acct_0138,parent_0006,contract_0149,2025-06-15,new,generated_history,320000,2300000,2620000
|
||||
arr_move_0099,acct_0139,parent_0007,contract_0150,2025-06-15,expansion,generated_history,330000,2400000,2730000
|
||||
arr_move_0100,acct_0140,parent_0008,contract_0151,2025-06-15,contraction,generated_history,-340000,2500000,2160000
|
||||
arr_move_0101,acct_0141,parent_0009,contract_0152,2025-06-15,reactivation,generated_history,350000,2600000,2950000
|
||||
arr_move_0102,acct_0142,parent_0010,contract_0153,2025-06-15,new,generated_history,360000,2700000,3060000
|
||||
arr_move_0103,acct_0143,parent_0011,contract_0154,2025-06-15,expansion,generated_history,370000,2800000,3170000
|
||||
arr_move_0104,acct_0144,parent_0012,contract_0155,2025-06-15,contraction,generated_history,-250000,2900000,2650000
|
||||
arr_move_0105,acct_0145,parent_0013,contract_0156,2025-06-15,reactivation,generated_history,260000,3000000,3260000
|
||||
arr_move_0106,acct_0146,parent_0014,contract_0157,2025-06-15,new,generated_history,270000,3100000,3370000
|
||||
arr_move_0107,acct_0147,parent_0015,contract_0158,2025-06-15,expansion,generated_history,280000,3200000,3480000
|
||||
arr_move_0108,acct_0148,parent_0016,contract_0159,2025-06-15,contraction,generated_history,-290000,3300000,3010000
|
||||
arr_move_0109,acct_0149,parent_0017,contract_0160,2025-06-15,reactivation,generated_history,300000,3400000,3700000
|
||||
arr_move_0110,acct_0150,parent_0018,contract_0161,2025-06-15,new,generated_history,310000,3500000,3810000
|
||||
arr_move_0111,acct_0151,parent_0019,contract_0162,2025-06-15,expansion,generated_history,320000,3600000,3920000
|
||||
arr_move_0112,acct_0152,parent_0020,contract_0163,2025-06-15,contraction,generated_history,-330000,3700000,3370000
|
||||
arr_move_0113,acct_0153,parent_0021,contract_0164,2025-06-15,reactivation,generated_history,340000,3800000,4140000
|
||||
arr_move_0114,acct_0154,parent_0022,contract_0165,2025-06-15,new,generated_history,350000,2000000,2350000
|
||||
arr_move_0115,acct_0155,parent_0023,contract_0166,2025-06-15,expansion,generated_history,360000,2100000,2460000
|
||||
arr_move_0116,acct_0156,parent_0024,contract_0167,2025-06-15,contraction,generated_history,-370000,2200000,1830000
|
||||
arr_move_0117,acct_0157,parent_0025,contract_0168,2025-06-15,reactivation,generated_history,250000,2300000,2550000
|
||||
arr_move_0118,acct_0158,parent_0026,contract_0169,2025-06-15,new,generated_history,260000,2400000,2660000
|
||||
arr_move_0119,acct_0159,parent_0027,contract_0170,2025-06-15,expansion,generated_history,270000,2500000,2770000
|
||||
arr_move_0120,acct_0160,parent_0028,contract_0171,2025-06-15,contraction,generated_history,-280000,2600000,2320000
|
||||
arr_move_0121,acct_0161,parent_0029,contract_0172,2025-06-15,reactivation,generated_history,290000,2700000,2990000
|
||||
arr_move_0122,acct_0162,parent_0030,contract_0173,2025-06-15,new,generated_history,300000,2800000,3100000
|
||||
arr_move_0123,acct_0163,parent_0031,contract_0174,2025-06-15,expansion,generated_history,310000,2900000,3210000
|
||||
arr_move_0124,acct_0164,parent_0032,contract_0175,2025-06-15,contraction,generated_history,-320000,3000000,2680000
|
||||
arr_move_0125,acct_0165,parent_0033,contract_0176,2025-06-15,reactivation,generated_history,330000,3100000,3430000
|
||||
arr_move_0126,acct_0166,parent_0034,contract_0177,2025-06-15,new,generated_history,340000,3200000,3540000
|
||||
arr_move_0127,acct_0167,parent_0035,contract_0178,2025-06-15,expansion,generated_history,350000,3300000,3650000
|
||||
arr_move_0128,acct_0168,parent_0036,contract_0179,2025-06-15,contraction,generated_history,-360000,3400000,3040000
|
||||
arr_move_0129,acct_0169,parent_0037,contract_0180,2025-06-15,reactivation,generated_history,370000,3500000,3870000
|
||||
arr_move_0130,acct_0170,parent_0038,contract_0181,2025-06-15,new,generated_history,250000,3600000,3850000
|
||||
arr_move_0131,acct_0171,parent_0039,contract_0182,2025-06-15,expansion,generated_history,260000,3700000,3960000
|
||||
arr_move_0132,acct_0172,parent_0040,contract_0183,2025-06-15,contraction,generated_history,-270000,3800000,3530000
|
||||
arr_move_0133,acct_0173,parent_0041,contract_0184,2025-06-15,reactivation,generated_history,280000,2000000,2280000
|
||||
arr_move_0134,acct_0174,parent_0042,contract_0185,2025-06-15,new,generated_history,290000,2100000,2390000
|
||||
arr_move_0135,acct_0175,parent_0043,contract_0186,2025-06-15,expansion,generated_history,300000,2200000,2500000
|
||||
arr_move_0136,acct_0176,parent_0044,contract_0187,2025-06-15,contraction,generated_history,-310000,2300000,1990000
|
||||
arr_move_0137,acct_0177,parent_0045,contract_0188,2025-06-15,reactivation,generated_history,320000,2400000,2720000
|
||||
arr_move_0138,acct_0178,parent_0046,contract_0189,2025-06-15,new,generated_history,330000,2500000,2830000
|
||||
arr_move_0139,acct_0179,parent_0047,contract_0190,2025-06-15,expansion,generated_history,340000,2600000,2940000
|
||||
arr_move_0140,acct_0180,parent_0048,contract_0191,2025-06-15,contraction,generated_history,-350000,2700000,2350000
|
||||
arr_move_0141,acct_0181,parent_0049,contract_0192,2025-06-15,reactivation,generated_history,360000,2800000,3160000
|
||||
arr_move_0142,acct_0182,parent_0050,contract_0193,2025-06-15,new,generated_history,370000,2900000,3270000
|
||||
arr_move_0143,acct_0183,parent_0051,contract_0194,2025-06-15,expansion,generated_history,250000,3000000,3250000
|
||||
arr_move_0144,acct_0184,parent_0052,contract_0195,2025-06-15,contraction,generated_history,-260000,3100000,2840000
|
||||
arr_move_0145,acct_0185,parent_0053,contract_0196,2025-06-15,reactivation,generated_history,270000,3200000,3470000
|
||||
arr_move_0146,acct_0186,parent_0054,contract_0197,2025-06-15,new,generated_history,280000,3300000,3580000
|
||||
arr_move_0147,acct_0187,parent_0055,contract_0198,2025-06-15,expansion,generated_history,290000,3400000,3690000
|
||||
arr_move_0148,acct_0188,parent_0056,contract_0199,2025-06-15,contraction,generated_history,-300000,3500000,3200000
|
||||
arr_move_0149,acct_0189,parent_0057,contract_0200,2025-06-15,reactivation,generated_history,310000,3600000,3910000
|
||||
arr_move_0150,acct_0090,parent_0090,contract_0201,2025-06-15,new,generated_history,320000,3700000,4020000
|
||||
arr_move_0151,acct_0091,parent_0091,contract_0202,2025-06-15,expansion,generated_history,330000,3800000,4130000
|
||||
arr_move_0152,acct_0092,parent_0092,contract_0203,2025-06-15,contraction,generated_history,-340000,2000000,1660000
|
||||
arr_move_0153,acct_0093,parent_0093,contract_0204,2025-06-15,reactivation,generated_history,350000,2100000,2450000
|
||||
arr_move_0154,acct_0094,parent_0094,contract_0205,2025-06-15,new,generated_history,360000,2200000,2560000
|
||||
arr_move_0155,acct_0095,parent_0095,contract_0206,2025-06-15,expansion,generated_history,370000,2300000,2670000
|
||||
arr_move_0156,acct_0096,parent_0096,contract_0207,2025-06-15,contraction,generated_history,-250000,2400000,2150000
|
||||
arr_move_0157,acct_0097,parent_0097,contract_0208,2025-06-15,reactivation,generated_history,260000,2500000,2760000
|
||||
arr_move_0158,acct_0098,parent_0098,contract_0209,2025-06-15,new,generated_history,270000,2600000,2870000
|
||||
arr_move_0159,acct_0099,parent_0099,contract_0210,2025-06-15,expansion,generated_history,280000,2700000,2980000
|
||||
arr_move_0160,acct_0100,parent_0100,contract_0211,2025-06-15,contraction,generated_history,-290000,2800000,2510000
|
||||
arr_move_0161,acct_0101,parent_0101,contract_0212,2025-06-15,reactivation,generated_history,300000,2900000,3200000
|
||||
arr_move_0162,acct_0102,parent_0102,contract_0213,2025-06-15,new,generated_history,310000,3000000,3310000
|
||||
arr_move_0163,acct_0103,parent_0103,contract_0214,2025-06-15,expansion,generated_history,320000,3100000,3420000
|
||||
arr_move_0164,acct_0104,parent_0104,contract_0215,2025-06-15,contraction,generated_history,-330000,3200000,2870000
|
||||
arr_move_0165,acct_0105,parent_0105,contract_0216,2025-06-15,reactivation,generated_history,340000,3300000,3640000
|
||||
arr_move_0166,acct_0106,parent_0106,contract_0217,2025-06-15,new,generated_history,350000,3400000,3750000
|
||||
arr_move_0167,acct_0107,parent_0107,contract_0218,2025-06-15,expansion,generated_history,360000,3500000,3860000
|
||||
arr_move_0168,acct_0108,parent_0108,contract_0219,2025-06-15,contraction,generated_history,-370000,3600000,3230000
|
||||
arr_move_0169,acct_0109,parent_0109,contract_0220,2025-06-15,reactivation,generated_history,250000,3700000,3950000
|
||||
arr_move_0170,acct_0110,parent_0110,contract_0221,2025-06-15,new,generated_history,260000,3800000,4060000
|
||||
arr_move_0171,acct_0111,parent_0111,contract_0222,2025-06-15,expansion,generated_history,270000,2000000,2270000
|
||||
arr_move_0172,acct_0112,parent_0112,contract_0223,2025-06-15,contraction,generated_history,-280000,2100000,1820000
|
||||
arr_move_0173,acct_0113,parent_0113,contract_0224,2025-06-15,reactivation,generated_history,290000,2200000,2490000
|
||||
arr_move_0174,acct_0114,parent_0114,contract_0225,2025-06-15,new,generated_history,300000,2300000,2600000
|
||||
arr_move_0175,acct_0115,parent_0115,contract_0226,2025-06-15,expansion,generated_history,310000,2400000,2710000
|
||||
arr_move_0176,acct_0116,parent_0116,contract_0227,2025-06-15,contraction,generated_history,-320000,2500000,2180000
|
||||
arr_move_0177,acct_0117,parent_0117,contract_0228,2025-06-15,reactivation,generated_history,330000,2600000,2930000
|
||||
arr_move_0178,acct_0118,parent_0118,contract_0229,2025-06-15,new,generated_history,340000,2700000,3040000
|
||||
arr_move_0179,acct_0119,parent_0119,contract_0230,2025-06-15,expansion,generated_history,350000,2800000,3150000
|
||||
arr_move_0180,acct_0120,parent_0120,contract_0231,2025-06-15,contraction,generated_history,-360000,2900000,2540000
|
||||
arr_move_0181,acct_0121,parent_0121,contract_0232,2025-06-15,reactivation,generated_history,370000,3000000,3370000
|
||||
arr_move_0182,acct_0122,parent_0122,contract_0233,2025-06-15,new,generated_history,250000,3100000,3350000
|
||||
arr_move_0183,acct_0123,parent_0123,contract_0234,2025-06-15,expansion,generated_history,260000,3200000,3460000
|
||||
arr_move_0184,acct_0124,parent_0124,contract_0235,2025-06-15,contraction,generated_history,-270000,3300000,3030000
|
||||
arr_move_0185,acct_0125,parent_0125,contract_0236,2025-06-15,reactivation,generated_history,280000,3400000,3680000
|
||||
arr_move_0186,acct_0126,parent_0126,contract_0237,2025-06-15,new,generated_history,290000,3500000,3790000
|
||||
arr_move_0187,acct_0127,parent_0127,contract_0238,2025-06-15,expansion,generated_history,300000,3600000,3900000
|
||||
arr_move_0188,acct_0128,parent_0128,contract_0239,2025-06-15,contraction,generated_history,-310000,3700000,3390000
|
||||
arr_move_0189,acct_0129,parent_0129,contract_0240,2025-06-15,reactivation,generated_history,320000,3800000,4120000
|
||||
arr_move_0190,acct_0130,parent_0130,contract_0241,2025-06-15,new,generated_history,330000,2000000,2330000
|
||||
arr_move_0191,acct_0131,parent_0131,contract_0242,2025-06-15,expansion,generated_history,340000,2100000,2440000
|
||||
arr_move_0192,acct_0132,parent_0132,contract_0243,2025-06-15,contraction,generated_history,-350000,2200000,1850000
|
||||
arr_move_0193,acct_0133,parent_0001,contract_0244,2025-06-15,reactivation,generated_history,360000,2300000,2660000
|
||||
arr_move_0194,acct_0134,parent_0002,contract_0245,2025-06-15,new,generated_history,370000,2400000,2770000
|
||||
arr_move_0195,acct_0135,parent_0003,contract_0246,2025-06-15,expansion,generated_history,250000,2500000,2750000
|
||||
arr_move_0196,acct_0136,parent_0004,contract_0247,2025-06-15,contraction,generated_history,-260000,2600000,2340000
|
||||
arr_move_0197,acct_0137,parent_0005,contract_0248,2025-06-15,reactivation,generated_history,270000,2700000,2970000
|
||||
arr_move_0198,acct_0138,parent_0006,contract_0249,2025-06-15,new,generated_history,280000,2800000,3080000
|
||||
arr_move_0199,acct_0139,parent_0007,contract_0250,2025-06-15,expansion,generated_history,290000,2900000,3190000
|
||||
arr_move_0200,acct_0140,parent_0008,contract_0251,2025-06-15,contraction,generated_history,-300000,3000000,2700000
|
||||
arr_move_0201,acct_0141,parent_0009,contract_0252,2025-06-15,reactivation,generated_history,310000,3100000,3410000
|
||||
arr_move_0202,acct_0142,parent_0010,contract_0253,2025-06-15,new,generated_history,320000,3200000,3520000
|
||||
arr_move_0203,acct_0143,parent_0011,contract_0254,2025-06-15,expansion,generated_history,330000,3300000,3630000
|
||||
arr_move_0204,acct_0144,parent_0012,contract_0255,2025-06-15,contraction,generated_history,-340000,3400000,3060000
|
||||
arr_move_0205,acct_0145,parent_0013,contract_0256,2025-06-15,reactivation,generated_history,350000,3500000,3850000
|
||||
arr_move_0206,acct_0146,parent_0014,contract_0257,2025-06-15,new,generated_history,360000,3600000,3960000
|
||||
arr_move_0207,acct_0147,parent_0015,contract_0258,2025-06-15,expansion,generated_history,370000,3700000,4070000
|
||||
arr_move_0208,acct_0148,parent_0016,contract_0259,2025-06-15,contraction,generated_history,-250000,3800000,3550000
|
||||
arr_move_0209,acct_0149,parent_0017,contract_0260,2025-06-15,reactivation,generated_history,260000,2000000,2260000
|
||||
arr_move_0210,acct_0150,parent_0018,contract_0261,2025-06-15,new,generated_history,270000,2100000,2370000
|
||||
arr_move_0211,acct_0151,parent_0019,contract_0262,2025-06-15,expansion,generated_history,280000,2200000,2480000
|
||||
arr_move_0212,acct_0152,parent_0020,contract_0263,2025-06-15,contraction,generated_history,-290000,2300000,2010000
|
||||
arr_move_0213,acct_0153,parent_0021,contract_0264,2025-06-15,reactivation,generated_history,300000,2400000,2700000
|
||||
arr_move_0214,acct_0154,parent_0022,contract_0265,2025-06-15,new,generated_history,310000,2500000,2810000
|
||||
arr_move_0215,acct_0155,parent_0023,contract_0266,2025-06-15,expansion,generated_history,320000,2600000,2920000
|
||||
arr_move_0216,acct_0156,parent_0024,contract_0267,2025-06-15,contraction,generated_history,-330000,2700000,2370000
|
||||
arr_move_0217,acct_0157,parent_0025,contract_0268,2025-06-15,reactivation,generated_history,340000,2800000,3140000
|
||||
arr_move_0218,acct_0158,parent_0026,contract_0269,2025-06-15,new,generated_history,350000,2900000,3250000
|
||||
arr_move_0219,acct_0159,parent_0027,contract_0270,2025-06-15,expansion,generated_history,360000,3000000,3360000
|
||||
arr_move_0220,acct_0160,parent_0028,contract_0271,2025-06-15,contraction,generated_history,-370000,3100000,2730000
|
||||
arr_move_0221,acct_0161,parent_0029,contract_0272,2025-06-15,reactivation,generated_history,250000,3200000,3450000
|
||||
arr_move_0222,acct_0162,parent_0030,contract_0273,2025-06-15,new,generated_history,260000,3300000,3560000
|
||||
arr_move_0223,acct_0163,parent_0031,contract_0274,2025-06-15,expansion,generated_history,270000,3400000,3670000
|
||||
arr_move_0224,acct_0164,parent_0032,contract_0275,2025-06-15,contraction,generated_history,-280000,3500000,3220000
|
||||
arr_move_0225,acct_0165,parent_0033,contract_0276,2025-06-15,reactivation,generated_history,290000,3600000,3890000
|
||||
arr_move_0226,acct_0166,parent_0034,contract_0277,2025-06-15,new,generated_history,300000,3700000,4000000
|
||||
arr_move_0227,acct_0167,parent_0035,contract_0278,2025-06-15,expansion,generated_history,310000,3800000,4110000
|
||||
arr_move_0228,acct_0168,parent_0036,contract_0279,2025-06-15,contraction,generated_history,-320000,2000000,1680000
|
||||
arr_move_0229,acct_0169,parent_0037,contract_0280,2025-06-15,reactivation,generated_history,330000,2100000,2430000
|
||||
arr_move_0230,acct_0170,parent_0038,contract_0281,2025-06-15,new,generated_history,340000,2200000,2540000
|
||||
arr_move_0231,acct_0171,parent_0039,contract_0282,2025-06-15,expansion,generated_history,350000,2300000,2650000
|
||||
arr_move_0232,acct_0172,parent_0040,contract_0283,2025-06-15,contraction,generated_history,-360000,2400000,2040000
|
||||
arr_move_0233,acct_0173,parent_0041,contract_0284,2025-06-15,reactivation,generated_history,370000,2500000,2870000
|
||||
arr_move_0234,acct_0174,parent_0042,contract_0285,2025-06-15,new,generated_history,250000,2600000,2850000
|
||||
arr_move_0235,acct_0175,parent_0043,contract_0286,2025-06-15,expansion,generated_history,260000,2700000,2960000
|
||||
arr_move_0236,acct_0176,parent_0044,contract_0287,2025-06-15,contraction,generated_history,-270000,2800000,2530000
|
||||
arr_move_0237,acct_0177,parent_0045,contract_0288,2025-06-15,reactivation,generated_history,280000,2900000,3180000
|
||||
arr_move_0238,acct_0178,parent_0046,contract_0289,2025-06-15,new,generated_history,290000,3000000,3290000
|
||||
arr_move_0239,acct_0179,parent_0047,contract_0290,2025-06-15,expansion,generated_history,300000,3100000,3400000
|
||||
arr_move_0240,acct_0180,parent_0048,contract_0291,2025-06-15,contraction,generated_history,-310000,3200000,2890000
|
||||
arr_move_0241,acct_0181,parent_0049,contract_0292,2025-06-15,reactivation,generated_history,320000,3300000,3620000
|
||||
arr_move_0242,acct_0182,parent_0050,contract_0293,2025-06-15,new,generated_history,330000,3400000,3730000
|
||||
arr_move_0243,acct_0183,parent_0051,contract_0294,2025-06-15,expansion,generated_history,340000,3500000,3840000
|
||||
arr_move_0244,acct_0184,parent_0052,contract_0295,2025-06-15,contraction,generated_history,-350000,3600000,3250000
|
||||
arr_move_0245,acct_0185,parent_0053,contract_0296,2025-06-15,reactivation,generated_history,360000,3700000,4060000
|
||||
arr_move_0246,acct_0186,parent_0054,contract_0297,2025-06-15,new,generated_history,370000,3800000,4170000
|
||||
arr_move_0247,acct_0187,parent_0055,contract_0298,2025-06-15,expansion,generated_history,250000,2000000,2250000
|
||||
arr_move_0248,acct_0188,parent_0056,contract_0299,2025-06-15,contraction,generated_history,-260000,2100000,1840000
|
||||
arr_move_0249,acct_0189,parent_0057,contract_0300,2025-06-15,reactivation,generated_history,270000,2200000,2470000
|
||||
arr_move_0250,acct_0090,parent_0090,contract_0301,2025-06-15,new,generated_history,280000,2300000,2580000
|
||||
arr_move_0251,acct_0091,parent_0091,contract_0302,2025-06-15,expansion,generated_history,290000,2400000,2690000
|
||||
arr_move_0252,acct_0092,parent_0092,contract_0303,2025-06-15,contraction,generated_history,-300000,2500000,2200000
|
||||
arr_move_0253,acct_0093,parent_0093,contract_0304,2025-06-15,reactivation,generated_history,310000,2600000,2910000
|
||||
arr_move_0254,acct_0094,parent_0094,contract_0305,2025-06-15,new,generated_history,320000,2700000,3020000
|
||||
arr_move_0255,acct_0095,parent_0095,contract_0306,2025-06-15,expansion,generated_history,330000,2800000,3130000
|
||||
arr_move_0256,acct_0096,parent_0096,contract_0307,2025-06-15,contraction,generated_history,-340000,2900000,2560000
|
||||
arr_move_0257,acct_0097,parent_0097,contract_0308,2025-06-15,reactivation,generated_history,350000,3000000,3350000
|
||||
arr_move_0258,acct_0098,parent_0098,contract_0309,2025-06-15,new,generated_history,360000,3100000,3460000
|
||||
arr_move_0259,acct_0099,parent_0099,contract_0310,2025-06-15,expansion,generated_history,370000,3200000,3570000
|
||||
arr_move_0260,acct_0100,parent_0100,contract_0311,2025-06-15,contraction,generated_history,-250000,3300000,3050000
|
||||
arr_move_0261,acct_0101,parent_0101,contract_0312,2025-06-15,reactivation,generated_history,260000,3400000,3660000
|
||||
arr_move_0262,acct_0102,parent_0102,contract_0313,2025-06-15,new,generated_history,270000,3500000,3770000
|
||||
arr_move_0263,acct_0103,parent_0103,contract_0314,2025-06-15,expansion,generated_history,280000,3600000,3880000
|
||||
arr_move_0264,acct_0104,parent_0104,contract_0315,2025-06-15,contraction,generated_history,-290000,3700000,3410000
|
||||
arr_move_0265,acct_0105,parent_0105,contract_0316,2025-06-15,reactivation,generated_history,300000,3800000,4100000
|
||||
arr_move_0266,acct_0106,parent_0106,contract_0317,2025-06-15,new,generated_history,310000,2000000,2310000
|
||||
arr_move_0267,acct_0107,parent_0107,contract_0318,2025-06-15,expansion,generated_history,320000,2100000,2420000
|
||||
arr_move_0268,acct_0108,parent_0108,contract_0319,2025-06-15,contraction,generated_history,-330000,2200000,1870000
|
||||
arr_move_0269,acct_0109,parent_0109,contract_0320,2025-06-15,reactivation,generated_history,340000,2300000,2640000
|
||||
arr_move_0270,acct_0110,parent_0110,contract_0101,2025-06-15,new,generated_history,350000,2400000,2750000
|
||||
arr_move_0271,acct_0111,parent_0111,contract_0102,2025-06-15,expansion,generated_history,360000,2500000,2860000
|
||||
arr_move_0272,acct_0112,parent_0112,contract_0103,2025-06-15,contraction,generated_history,-370000,2600000,2230000
|
||||
arr_move_0273,acct_0113,parent_0113,contract_0104,2025-06-15,reactivation,generated_history,250000,2700000,2950000
|
||||
arr_move_0274,acct_0114,parent_0114,contract_0105,2025-06-15,new,generated_history,260000,2800000,3060000
|
||||
arr_move_0275,acct_0115,parent_0115,contract_0106,2025-06-15,expansion,generated_history,270000,2900000,3170000
|
||||
arr_move_0276,acct_0116,parent_0116,contract_0107,2025-06-15,contraction,generated_history,-280000,3000000,2720000
|
||||
arr_move_0277,acct_0117,parent_0117,contract_0108,2025-06-15,reactivation,generated_history,290000,3100000,3390000
|
||||
arr_move_0278,acct_0118,parent_0118,contract_0109,2025-06-15,new,generated_history,300000,3200000,3500000
|
||||
arr_move_0279,acct_0119,parent_0119,contract_0110,2025-06-15,expansion,generated_history,310000,3300000,3610000
|
||||
arr_move_0280,acct_0120,parent_0120,contract_0111,2025-06-15,contraction,generated_history,-320000,3400000,3080000
|
||||
arr_move_0281,acct_0121,parent_0121,contract_0112,2025-06-15,reactivation,generated_history,330000,3500000,3830000
|
||||
arr_move_0282,acct_0122,parent_0122,contract_0113,2025-06-15,new,generated_history,340000,3600000,3940000
|
||||
arr_move_0283,acct_0123,parent_0123,contract_0114,2025-06-15,expansion,generated_history,350000,3700000,4050000
|
||||
arr_move_0284,acct_0124,parent_0124,contract_0115,2025-06-15,contraction,generated_history,-360000,3800000,3440000
|
||||
arr_move_0285,acct_0125,parent_0125,contract_0116,2025-06-15,reactivation,generated_history,370000,2000000,2370000
|
||||
arr_move_0286,acct_0126,parent_0126,contract_0117,2025-06-15,new,generated_history,250000,2100000,2350000
|
||||
arr_move_0287,acct_0127,parent_0127,contract_0118,2025-06-15,expansion,generated_history,260000,2200000,2460000
|
||||
arr_move_0288,acct_0128,parent_0128,contract_0119,2025-06-15,contraction,generated_history,-270000,2300000,2030000
|
||||
arr_move_0289,acct_0129,parent_0129,contract_0120,2025-06-15,reactivation,generated_history,280000,2400000,2680000
|
||||
arr_move_0290,acct_0130,parent_0130,contract_0121,2025-06-15,new,generated_history,290000,2500000,2790000
|
||||
arr_move_0291,acct_0131,parent_0131,contract_0122,2025-06-15,expansion,generated_history,300000,2600000,2900000
|
||||
arr_move_0292,acct_0132,parent_0132,contract_0123,2025-06-15,contraction,generated_history,-310000,2700000,2390000
|
||||
arr_move_0293,acct_0133,parent_0001,contract_0124,2025-06-15,reactivation,generated_history,320000,2800000,3120000
|
||||
arr_move_0294,acct_0134,parent_0002,contract_0125,2025-06-15,new,generated_history,330000,2900000,3230000
|
||||
arr_move_0295,acct_0135,parent_0003,contract_0126,2025-06-15,expansion,generated_history,340000,3000000,3340000
|
||||
arr_move_0296,acct_0136,parent_0004,contract_0127,2025-06-15,contraction,generated_history,-350000,3100000,2750000
|
||||
arr_move_0297,acct_0137,parent_0005,contract_0128,2025-06-15,reactivation,generated_history,360000,3200000,3560000
|
||||
arr_move_0298,acct_0138,parent_0006,contract_0129,2025-06-15,new,generated_history,370000,3300000,3670000
|
||||
arr_move_0299,acct_0139,parent_0007,contract_0130,2025-06-15,expansion,generated_history,250000,3400000,3650000
|
||||
arr_move_0300,acct_0140,parent_0008,contract_0131,2025-06-15,contraction,generated_history,-260000,3500000,3240000
|
||||
arr_move_0301,acct_0141,parent_0009,contract_0132,2025-06-15,reactivation,generated_history,270000,3600000,3870000
|
||||
arr_move_0302,acct_0142,parent_0010,contract_0133,2025-06-15,new,generated_history,280000,3700000,3980000
|
||||
arr_move_0303,acct_0143,parent_0011,contract_0134,2025-06-15,expansion,generated_history,290000,3800000,4090000
|
||||
arr_move_0304,acct_0144,parent_0012,contract_0135,2025-06-15,contraction,generated_history,-300000,2000000,1700000
|
||||
arr_move_0305,acct_0145,parent_0013,contract_0136,2025-06-15,reactivation,generated_history,310000,2100000,2410000
|
||||
arr_move_0306,acct_0146,parent_0014,contract_0137,2025-06-15,new,generated_history,320000,2200000,2520000
|
||||
arr_move_0307,acct_0147,parent_0015,contract_0138,2025-06-15,expansion,generated_history,330000,2300000,2630000
|
||||
arr_move_0308,acct_0148,parent_0016,contract_0139,2025-06-15,contraction,generated_history,-340000,2400000,2060000
|
||||
arr_move_0309,acct_0149,parent_0017,contract_0140,2025-06-15,reactivation,generated_history,350000,2500000,2850000
|
||||
arr_move_0310,acct_0150,parent_0018,contract_0141,2025-06-15,new,generated_history,360000,2600000,2960000
|
||||
arr_move_0311,acct_0151,parent_0019,contract_0142,2025-06-15,expansion,generated_history,370000,2700000,3070000
|
||||
arr_move_0312,acct_0152,parent_0020,contract_0143,2025-06-15,contraction,generated_history,-250000,2800000,2550000
|
||||
arr_move_0313,acct_0153,parent_0021,contract_0144,2025-06-15,reactivation,generated_history,260000,2900000,3160000
|
||||
arr_move_0314,acct_0154,parent_0022,contract_0145,2025-06-15,new,generated_history,270000,3000000,3270000
|
||||
arr_move_0315,acct_0155,parent_0023,contract_0146,2025-06-15,expansion,generated_history,280000,3100000,3380000
|
||||
arr_move_0316,acct_0156,parent_0024,contract_0147,2025-06-15,contraction,generated_history,-290000,3200000,2910000
|
||||
arr_move_0317,acct_0157,parent_0025,contract_0148,2025-06-15,reactivation,generated_history,300000,3300000,3600000
|
||||
arr_move_0318,acct_0158,parent_0026,contract_0149,2025-06-15,new,generated_history,310000,3400000,3710000
|
||||
arr_move_0319,acct_0159,parent_0027,contract_0150,2025-06-15,expansion,generated_history,320000,3500000,3820000
|
||||
arr_move_0320,acct_0160,parent_0028,contract_0151,2025-06-15,contraction,generated_history,-330000,3600000,3270000
|
||||
arr_move_0321,acct_0161,parent_0029,contract_0152,2025-06-15,reactivation,generated_history,340000,3700000,4040000
|
||||
arr_move_0322,acct_0162,parent_0030,contract_0153,2025-06-15,new,generated_history,350000,3800000,4150000
|
||||
arr_move_0323,acct_0163,parent_0031,contract_0154,2025-06-15,expansion,generated_history,360000,2000000,2360000
|
||||
arr_move_0324,acct_0164,parent_0032,contract_0155,2025-06-15,contraction,generated_history,-370000,2100000,1730000
|
||||
arr_move_0325,acct_0165,parent_0033,contract_0156,2025-06-15,reactivation,generated_history,250000,2200000,2450000
|
||||
arr_move_0326,acct_0166,parent_0034,contract_0157,2025-06-15,new,generated_history,260000,2300000,2560000
|
||||
arr_move_0327,acct_0167,parent_0035,contract_0158,2025-06-15,expansion,generated_history,270000,2400000,2670000
|
||||
arr_move_0328,acct_0168,parent_0036,contract_0159,2025-06-15,contraction,generated_history,-280000,2500000,2220000
|
||||
arr_move_0329,acct_0169,parent_0037,contract_0160,2025-06-15,reactivation,generated_history,290000,2600000,2890000
|
||||
arr_move_0330,acct_0170,parent_0038,contract_0161,2025-06-15,new,generated_history,300000,2700000,3000000
|
||||
arr_move_0331,acct_0171,parent_0039,contract_0162,2025-06-15,expansion,generated_history,310000,2800000,3110000
|
||||
arr_move_0332,acct_0172,parent_0040,contract_0163,2025-06-15,contraction,generated_history,-320000,2900000,2580000
|
||||
arr_move_0333,acct_0173,parent_0041,contract_0164,2025-06-15,reactivation,generated_history,330000,3000000,3330000
|
||||
arr_move_0334,acct_0174,parent_0042,contract_0165,2025-06-15,new,generated_history,340000,3100000,3440000
|
||||
arr_move_0335,acct_0175,parent_0043,contract_0166,2025-06-15,expansion,generated_history,350000,3200000,3550000
|
||||
arr_move_0336,acct_0176,parent_0044,contract_0167,2025-06-15,contraction,generated_history,-360000,3300000,2940000
|
||||
arr_move_0337,acct_0177,parent_0045,contract_0168,2025-06-15,reactivation,generated_history,370000,3400000,3770000
|
||||
arr_move_0338,acct_0178,parent_0046,contract_0169,2025-06-15,new,generated_history,250000,3500000,3750000
|
||||
arr_move_0339,acct_0179,parent_0047,contract_0170,2025-06-15,expansion,generated_history,260000,3600000,3860000
|
||||
arr_move_0340,acct_0180,parent_0048,contract_0171,2025-06-15,contraction,generated_history,-270000,3700000,3430000
|
||||
arr_move_0341,acct_0181,parent_0049,contract_0172,2025-06-15,reactivation,generated_history,280000,3800000,4080000
|
||||
arr_move_0342,acct_0182,parent_0050,contract_0173,2025-06-15,new,generated_history,290000,2000000,2290000
|
||||
arr_move_0343,acct_0183,parent_0051,contract_0174,2025-06-15,expansion,generated_history,300000,2100000,2400000
|
||||
arr_move_0344,acct_0184,parent_0052,contract_0175,2025-06-15,contraction,generated_history,-310000,2200000,1890000
|
||||
arr_move_0345,acct_0185,parent_0053,contract_0176,2025-06-15,reactivation,generated_history,320000,2300000,2620000
|
||||
arr_move_0346,acct_0186,parent_0054,contract_0177,2025-06-15,new,generated_history,330000,2400000,2730000
|
||||
arr_move_0347,acct_0187,parent_0055,contract_0178,2025-06-15,expansion,generated_history,340000,2500000,2840000
|
||||
arr_move_0348,acct_0188,parent_0056,contract_0179,2025-06-15,contraction,generated_history,-350000,2600000,2250000
|
||||
arr_move_0349,acct_0189,parent_0057,contract_0180,2025-06-15,reactivation,generated_history,360000,2700000,3060000
|
||||
arr_move_0350,acct_0090,parent_0090,contract_0181,2025-06-15,new,generated_history,370000,2800000,3170000
|
||||
arr_move_0351,acct_0091,parent_0091,contract_0182,2025-06-15,expansion,generated_history,250000,2900000,3150000
|
||||
arr_move_0352,acct_0092,parent_0092,contract_0183,2025-06-15,contraction,generated_history,-260000,3000000,2740000
|
||||
arr_move_0353,acct_0093,parent_0093,contract_0184,2025-06-15,reactivation,generated_history,270000,3100000,3370000
|
||||
arr_move_0354,acct_0094,parent_0094,contract_0185,2025-06-15,new,generated_history,280000,3200000,3480000
|
||||
arr_move_0355,acct_0095,parent_0095,contract_0186,2025-06-15,expansion,generated_history,290000,3300000,3590000
|
||||
arr_move_0356,acct_0096,parent_0096,contract_0187,2025-06-15,contraction,generated_history,-300000,3400000,3100000
|
||||
arr_move_0357,acct_0097,parent_0097,contract_0188,2025-06-15,reactivation,generated_history,310000,3500000,3810000
|
||||
arr_move_0358,acct_0098,parent_0098,contract_0189,2025-06-15,new,generated_history,320000,3600000,3920000
|
||||
arr_move_0359,acct_0099,parent_0099,contract_0190,2025-06-15,expansion,generated_history,330000,3700000,4030000
|
||||
arr_move_0360,acct_0100,parent_0100,contract_0191,2025-06-15,contraction,generated_history,-340000,3800000,3460000
|
||||
arr_move_0361,acct_0101,parent_0101,contract_0192,2025-06-15,reactivation,generated_history,350000,2000000,2350000
|
||||
arr_move_0362,acct_0102,parent_0102,contract_0193,2025-06-15,new,generated_history,360000,2100000,2460000
|
||||
arr_move_0363,acct_0103,parent_0103,contract_0194,2025-06-15,expansion,generated_history,370000,2200000,2570000
|
||||
arr_move_0364,acct_0104,parent_0104,contract_0195,2025-06-15,contraction,generated_history,-250000,2300000,2050000
|
||||
arr_move_0365,acct_0105,parent_0105,contract_0196,2025-06-15,reactivation,generated_history,260000,2400000,2660000
|
||||
arr_move_0366,acct_0106,parent_0106,contract_0197,2025-06-15,new,generated_history,270000,2500000,2770000
|
||||
arr_move_0367,acct_0107,parent_0107,contract_0198,2025-06-15,expansion,generated_history,280000,2600000,2880000
|
||||
arr_move_0368,acct_0108,parent_0108,contract_0199,2025-06-15,contraction,generated_history,-290000,2700000,2410000
|
||||
arr_move_0369,acct_0109,parent_0109,contract_0200,2025-06-15,reactivation,generated_history,300000,2800000,3100000
|
||||
arr_move_0370,acct_0110,parent_0110,contract_0201,2025-06-15,new,generated_history,310000,2900000,3210000
|
||||
arr_move_0371,acct_0111,parent_0111,contract_0202,2025-06-15,expansion,generated_history,320000,3000000,3320000
|
||||
arr_move_0372,acct_0112,parent_0112,contract_0203,2025-06-15,contraction,generated_history,-330000,3100000,2770000
|
||||
arr_move_0373,acct_0113,parent_0113,contract_0204,2025-06-15,reactivation,generated_history,340000,3200000,3540000
|
||||
arr_move_0374,acct_0114,parent_0114,contract_0205,2025-06-15,new,generated_history,350000,3300000,3650000
|
||||
arr_move_0375,acct_0115,parent_0115,contract_0206,2025-06-15,expansion,generated_history,360000,3400000,3760000
|
||||
arr_move_0376,acct_0116,parent_0116,contract_0207,2025-06-15,contraction,generated_history,-370000,3500000,3130000
|
||||
arr_move_0377,acct_0117,parent_0117,contract_0208,2025-06-15,reactivation,generated_history,250000,3600000,3850000
|
||||
arr_move_0378,acct_0118,parent_0118,contract_0209,2025-06-15,new,generated_history,260000,3700000,3960000
|
||||
arr_move_0379,acct_0119,parent_0119,contract_0210,2025-06-15,expansion,generated_history,270000,3800000,4070000
|
||||
arr_move_0380,acct_0120,parent_0120,contract_0211,2025-06-15,contraction,generated_history,-280000,2000000,1720000
|
||||
arr_move_0381,acct_0121,parent_0121,contract_0212,2025-06-15,reactivation,generated_history,290000,2100000,2390000
|
||||
arr_move_0382,acct_0122,parent_0122,contract_0213,2025-06-15,new,generated_history,300000,2200000,2500000
|
||||
arr_move_0383,acct_0123,parent_0123,contract_0214,2025-06-15,expansion,generated_history,310000,2300000,2610000
|
||||
arr_move_0384,acct_0124,parent_0124,contract_0215,2025-06-15,contraction,generated_history,-320000,2400000,2080000
|
||||
arr_move_0385,acct_0125,parent_0125,contract_0216,2025-06-15,reactivation,generated_history,330000,2500000,2830000
|
||||
arr_move_0386,acct_0126,parent_0126,contract_0217,2025-06-15,new,generated_history,340000,2600000,2940000
|
||||
arr_move_0387,acct_0127,parent_0127,contract_0218,2025-06-15,expansion,generated_history,350000,2700000,3050000
|
||||
arr_move_0388,acct_0128,parent_0128,contract_0219,2025-06-15,contraction,generated_history,-360000,2800000,2440000
|
||||
arr_move_0389,acct_0129,parent_0129,contract_0220,2025-06-15,reactivation,generated_history,370000,2900000,3270000
|
||||
arr_move_0390,acct_0130,parent_0130,contract_0221,2025-06-15,new,generated_history,250000,3000000,3250000
|
||||
arr_move_0391,acct_0131,parent_0131,contract_0222,2025-06-15,expansion,generated_history,260000,3100000,3360000
|
||||
arr_move_0392,acct_0132,parent_0132,contract_0223,2025-06-15,contraction,generated_history,-270000,3200000,2930000
|
||||
arr_move_0393,acct_0133,parent_0001,contract_0224,2025-06-15,reactivation,generated_history,280000,3300000,3580000
|
||||
arr_move_0394,acct_0134,parent_0002,contract_0225,2025-06-15,new,generated_history,290000,3400000,3690000
|
||||
arr_move_0395,acct_0135,parent_0003,contract_0226,2025-06-15,expansion,generated_history,300000,3500000,3800000
|
||||
arr_move_0396,acct_0136,parent_0004,contract_0227,2025-06-15,contraction,generated_history,-310000,3600000,3290000
|
||||
arr_move_0397,acct_0137,parent_0005,contract_0228,2025-06-15,reactivation,generated_history,320000,3700000,4020000
|
||||
arr_move_0398,acct_0138,parent_0006,contract_0229,2025-06-15,new,generated_history,330000,3800000,4130000
|
||||
arr_move_0399,acct_0139,parent_0007,contract_0230,2025-06-15,expansion,generated_history,340000,2000000,2340000
|
||||
arr_move_0400,acct_0140,parent_0008,contract_0231,2025-06-15,contraction,generated_history,-350000,2100000,1750000
|
||||
arr_move_0401,acct_0141,parent_0009,contract_0232,2025-06-15,reactivation,generated_history,360000,2200000,2560000
|
||||
arr_move_0402,acct_0142,parent_0010,contract_0233,2025-06-15,new,generated_history,370000,2300000,2670000
|
||||
arr_move_0403,acct_0143,parent_0011,contract_0234,2025-06-15,expansion,generated_history,250000,2400000,2650000
|
||||
arr_move_0404,acct_0144,parent_0012,contract_0235,2025-06-15,contraction,generated_history,-260000,2500000,2240000
|
||||
arr_move_0405,acct_0145,parent_0013,contract_0236,2025-06-15,reactivation,generated_history,270000,2600000,2870000
|
||||
arr_move_0406,acct_0146,parent_0014,contract_0237,2025-06-15,new,generated_history,280000,2700000,2980000
|
||||
arr_move_0407,acct_0147,parent_0015,contract_0238,2025-06-15,expansion,generated_history,290000,2800000,3090000
|
||||
arr_move_0408,acct_0148,parent_0016,contract_0239,2025-06-15,contraction,generated_history,-300000,2900000,2600000
|
||||
arr_move_0409,acct_0149,parent_0017,contract_0240,2025-06-15,reactivation,generated_history,310000,3000000,3310000
|
||||
arr_move_0410,acct_0150,parent_0018,contract_0241,2025-06-15,new,generated_history,320000,3100000,3420000
|
||||
arr_move_0411,acct_0151,parent_0019,contract_0242,2025-06-15,expansion,generated_history,330000,3200000,3530000
|
||||
arr_move_0412,acct_0152,parent_0020,contract_0243,2025-06-15,contraction,generated_history,-340000,3300000,2960000
|
||||
arr_move_0413,acct_0153,parent_0021,contract_0244,2025-06-15,reactivation,generated_history,350000,3400000,3750000
|
||||
arr_move_0414,acct_0154,parent_0022,contract_0245,2025-06-15,new,generated_history,360000,3500000,3860000
|
||||
arr_move_0415,acct_0155,parent_0023,contract_0246,2025-06-15,expansion,generated_history,370000,3600000,3970000
|
||||
arr_move_0416,acct_0156,parent_0024,contract_0247,2025-06-15,contraction,generated_history,-250000,3700000,3450000
|
||||
arr_move_0417,acct_0157,parent_0025,contract_0248,2025-06-15,reactivation,generated_history,260000,3800000,4060000
|
||||
arr_move_0418,acct_0158,parent_0026,contract_0249,2025-06-15,new,generated_history,270000,2000000,2270000
|
||||
arr_move_0419,acct_0159,parent_0027,contract_0250,2025-06-15,expansion,generated_history,280000,2100000,2380000
|
||||
arr_move_0420,acct_0160,parent_0028,contract_0251,2025-06-15,contraction,generated_history,-290000,2200000,1910000
|
||||
arr_move_0421,acct_0161,parent_0029,contract_0252,2025-06-15,reactivation,generated_history,300000,2300000,2600000
|
||||
arr_move_0422,acct_0162,parent_0030,contract_0253,2025-06-15,new,generated_history,310000,2400000,2710000
|
||||
arr_move_0423,acct_0163,parent_0031,contract_0254,2025-06-15,expansion,generated_history,320000,2500000,2820000
|
||||
arr_move_0424,acct_0164,parent_0032,contract_0255,2025-06-15,contraction,generated_history,-330000,2600000,2270000
|
||||
arr_move_0425,acct_0165,parent_0033,contract_0256,2025-06-15,reactivation,generated_history,340000,2700000,3040000
|
||||
arr_move_0426,acct_0166,parent_0034,contract_0257,2025-06-15,new,generated_history,350000,2800000,3150000
|
||||
arr_move_0427,acct_0167,parent_0035,contract_0258,2025-06-15,expansion,generated_history,360000,2900000,3260000
|
||||
arr_move_0428,acct_0168,parent_0036,contract_0259,2025-06-15,contraction,generated_history,-370000,3000000,2630000
|
||||
arr_move_0429,acct_0169,parent_0037,contract_0260,2025-06-15,reactivation,generated_history,250000,3100000,3350000
|
||||
arr_move_0430,acct_0170,parent_0038,contract_0261,2025-06-15,new,generated_history,260000,3200000,3460000
|
||||
arr_move_0431,acct_0171,parent_0039,contract_0262,2025-06-15,expansion,generated_history,270000,3300000,3570000
|
||||
arr_move_0432,acct_0172,parent_0040,contract_0263,2025-06-15,contraction,generated_history,-280000,3400000,3120000
|
||||
arr_move_0433,acct_0173,parent_0041,contract_0264,2025-06-15,reactivation,generated_history,290000,3500000,3790000
|
||||
arr_move_0434,acct_0174,parent_0042,contract_0265,2025-06-15,new,generated_history,300000,3600000,3900000
|
||||
arr_move_0435,acct_0175,parent_0043,contract_0266,2025-06-15,expansion,generated_history,310000,3700000,4010000
|
||||
arr_move_0436,acct_0176,parent_0044,contract_0267,2025-06-15,contraction,generated_history,-320000,3800000,3480000
|
||||
arr_move_0437,acct_0177,parent_0045,contract_0268,2025-06-15,reactivation,generated_history,330000,2000000,2330000
|
||||
arr_move_0438,acct_0178,parent_0046,contract_0269,2025-06-15,new,generated_history,340000,2100000,2440000
|
||||
arr_move_0439,acct_0179,parent_0047,contract_0270,2025-06-15,expansion,generated_history,350000,2200000,2550000
|
||||
arr_move_0440,acct_0180,parent_0048,contract_0271,2025-06-15,contraction,generated_history,-360000,2300000,1940000
|
||||
arr_move_0441,acct_0181,parent_0049,contract_0272,2025-06-15,reactivation,generated_history,370000,2400000,2770000
|
||||
arr_move_0442,acct_0182,parent_0050,contract_0273,2025-06-15,new,generated_history,250000,2500000,2750000
|
||||
arr_move_0443,acct_0183,parent_0051,contract_0274,2025-06-15,expansion,generated_history,260000,2600000,2860000
|
||||
arr_move_0444,acct_0184,parent_0052,contract_0275,2025-06-15,contraction,generated_history,-270000,2700000,2430000
|
||||
arr_move_0445,acct_0185,parent_0053,contract_0276,2025-06-15,reactivation,generated_history,280000,2800000,3080000
|
||||
arr_move_0446,acct_0186,parent_0054,contract_0277,2025-06-15,new,generated_history,290000,2900000,3190000
|
||||
arr_move_0447,acct_0187,parent_0055,contract_0278,2025-06-15,expansion,generated_history,300000,3000000,3300000
|
||||
arr_move_0448,acct_0188,parent_0056,contract_0279,2025-06-15,contraction,generated_history,-310000,3100000,2790000
|
||||
arr_move_0449,acct_0189,parent_0057,contract_0280,2025-06-15,reactivation,generated_history,320000,3200000,3520000
|
||||
arr_move_0450,acct_0090,parent_0090,contract_0281,2025-06-15,new,generated_history,330000,3300000,3630000
|
||||
arr_move_0451,acct_0091,parent_0091,contract_0282,2025-06-15,expansion,generated_history,340000,3400000,3740000
|
||||
arr_move_0452,acct_0092,parent_0092,contract_0283,2025-06-15,contraction,generated_history,-350000,3500000,3150000
|
||||
arr_move_0453,acct_0093,parent_0093,contract_0284,2025-06-15,reactivation,generated_history,360000,3600000,3960000
|
||||
arr_move_0454,acct_0094,parent_0094,contract_0285,2025-06-15,new,generated_history,370000,3700000,4070000
|
||||
arr_move_0455,acct_0095,parent_0095,contract_0286,2025-06-15,expansion,generated_history,250000,3800000,4050000
|
||||
arr_move_0456,acct_0096,parent_0096,contract_0287,2025-06-15,contraction,generated_history,-260000,2000000,1740000
|
||||
arr_move_0457,acct_0097,parent_0097,contract_0288,2025-06-15,reactivation,generated_history,270000,2100000,2370000
|
||||
arr_move_0458,acct_0098,parent_0098,contract_0289,2025-06-15,new,generated_history,280000,2200000,2480000
|
||||
arr_move_0459,acct_0099,parent_0099,contract_0290,2025-06-15,expansion,generated_history,290000,2300000,2590000
|
||||
arr_move_0460,acct_0100,parent_0100,contract_0291,2025-06-15,contraction,generated_history,-300000,2400000,2100000
|
||||
arr_move_0461,acct_0101,parent_0101,contract_0292,2025-06-15,reactivation,generated_history,310000,2500000,2810000
|
||||
arr_move_0462,acct_0102,parent_0102,contract_0293,2025-06-15,new,generated_history,320000,2600000,2920000
|
||||
arr_move_0463,acct_0103,parent_0103,contract_0294,2025-06-15,expansion,generated_history,330000,2700000,3030000
|
||||
arr_move_0464,acct_0104,parent_0104,contract_0295,2025-06-15,contraction,generated_history,-340000,2800000,2460000
|
||||
arr_move_0465,acct_0105,parent_0105,contract_0296,2025-06-15,reactivation,generated_history,350000,2900000,3250000
|
||||
arr_move_0466,acct_0106,parent_0106,contract_0297,2025-06-15,new,generated_history,360000,3000000,3360000
|
||||
arr_move_0467,acct_0107,parent_0107,contract_0298,2025-06-15,expansion,generated_history,370000,3100000,3470000
|
||||
arr_move_0468,acct_0108,parent_0108,contract_0299,2025-06-15,contraction,generated_history,-250000,3200000,2950000
|
||||
arr_move_0469,acct_0109,parent_0109,contract_0300,2025-06-15,reactivation,generated_history,260000,3300000,3560000
|
||||
arr_move_0470,acct_0110,parent_0110,contract_0301,2025-06-15,new,generated_history,270000,3400000,3670000
|
||||
arr_move_0471,acct_0111,parent_0111,contract_0302,2025-06-15,expansion,generated_history,280000,3500000,3780000
|
||||
arr_move_0472,acct_0112,parent_0112,contract_0303,2025-06-15,contraction,generated_history,-290000,3600000,3310000
|
||||
arr_move_0473,acct_0113,parent_0113,contract_0304,2025-06-15,reactivation,generated_history,300000,3700000,4000000
|
||||
arr_move_0474,acct_0114,parent_0114,contract_0305,2025-06-15,new,generated_history,310000,3800000,4110000
|
||||
arr_move_0475,acct_0115,parent_0115,contract_0306,2025-06-15,expansion,generated_history,320000,2000000,2320000
|
||||
arr_move_0476,acct_0116,parent_0116,contract_0307,2025-06-15,contraction,generated_history,-330000,2100000,1770000
|
||||
arr_move_0477,acct_0117,parent_0117,contract_0308,2025-06-15,reactivation,generated_history,340000,2200000,2540000
|
||||
arr_move_0478,acct_0118,parent_0118,contract_0309,2025-06-15,new,generated_history,350000,2300000,2650000
|
||||
arr_move_0479,acct_0119,parent_0119,contract_0310,2025-06-15,expansion,generated_history,360000,2400000,2760000
|
||||
arr_move_0480,acct_0120,parent_0120,contract_0311,2025-06-15,contraction,generated_history,-370000,2500000,2130000
|
||||
arr_move_0481,acct_0121,parent_0121,contract_0312,2025-06-15,reactivation,generated_history,250000,2600000,2850000
|
||||
arr_move_0482,acct_0122,parent_0122,contract_0313,2025-06-15,new,generated_history,260000,2700000,2960000
|
||||
arr_move_0483,acct_0123,parent_0123,contract_0314,2025-06-15,expansion,generated_history,270000,2800000,3070000
|
||||
arr_move_0484,acct_0124,parent_0124,contract_0315,2025-06-15,contraction,generated_history,-280000,2900000,2620000
|
||||
arr_move_0485,acct_0125,parent_0125,contract_0316,2025-06-15,reactivation,generated_history,290000,3000000,3290000
|
||||
arr_move_0486,acct_0126,parent_0126,contract_0317,2025-06-15,new,generated_history,300000,3100000,3400000
|
||||
arr_move_0487,acct_0127,parent_0127,contract_0318,2025-06-15,expansion,generated_history,310000,3200000,3510000
|
||||
arr_move_0488,acct_0128,parent_0128,contract_0319,2025-06-15,contraction,generated_history,-320000,3300000,2980000
|
||||
arr_move_0489,acct_0129,parent_0129,contract_0320,2025-06-15,reactivation,generated_history,330000,3400000,3730000
|
||||
arr_move_0490,acct_0130,parent_0130,contract_0101,2025-06-15,new,generated_history,340000,3500000,3840000
|
||||
arr_move_0491,acct_0131,parent_0131,contract_0102,2025-06-15,expansion,generated_history,350000,3600000,3950000
|
||||
arr_move_0492,acct_0132,parent_0132,contract_0103,2025-06-15,contraction,generated_history,-360000,3700000,3340000
|
||||
arr_move_0493,acct_0133,parent_0001,contract_0104,2025-06-15,reactivation,generated_history,370000,3800000,4170000
|
||||
arr_move_0494,acct_0134,parent_0002,contract_0105,2025-06-15,new,generated_history,250000,2000000,2250000
|
||||
arr_move_0495,acct_0135,parent_0003,contract_0106,2025-06-15,expansion,generated_history,260000,2100000,2360000
|
||||
arr_move_0496,acct_0136,parent_0004,contract_0107,2025-06-15,contraction,generated_history,-270000,2200000,1930000
|
||||
arr_move_0497,acct_0137,parent_0005,contract_0108,2025-06-15,reactivation,generated_history,280000,2300000,2580000
|
||||
arr_move_0498,acct_0138,parent_0006,contract_0109,2025-06-15,new,generated_history,290000,2400000,2690000
|
||||
arr_move_0499,acct_0139,parent_0007,contract_0110,2025-06-15,expansion,generated_history,300000,2500000,2800000
|
||||
arr_move_0500,acct_0140,parent_0008,contract_0111,2025-06-15,contraction,generated_history,-310000,2600000,2290000
|
||||
arr_move_0501,acct_0141,parent_0009,contract_0112,2025-06-15,reactivation,generated_history,320000,2700000,3020000
|
||||
arr_move_0502,acct_0142,parent_0010,contract_0113,2025-06-15,new,generated_history,330000,2800000,3130000
|
||||
arr_move_0503,acct_0143,parent_0011,contract_0114,2025-06-15,expansion,generated_history,340000,2900000,3240000
|
||||
arr_move_0504,acct_0144,parent_0012,contract_0115,2025-06-15,contraction,generated_history,-350000,3000000,2650000
|
||||
arr_move_0505,acct_0145,parent_0013,contract_0116,2025-06-15,reactivation,generated_history,360000,3100000,3460000
|
||||
arr_move_0506,acct_0146,parent_0014,contract_0117,2025-06-15,new,generated_history,370000,3200000,3570000
|
||||
arr_move_0507,acct_0147,parent_0015,contract_0118,2025-06-15,expansion,generated_history,250000,3300000,3550000
|
||||
arr_move_0508,acct_0148,parent_0016,contract_0119,2025-06-15,contraction,generated_history,-260000,3400000,3140000
|
||||
arr_move_0509,acct_0149,parent_0017,contract_0120,2025-06-15,reactivation,generated_history,270000,3500000,3770000
|
||||
arr_move_0510,acct_0150,parent_0018,contract_0121,2025-06-15,new,generated_history,280000,3600000,3880000
|
||||
arr_move_0511,acct_0151,parent_0019,contract_0122,2025-06-15,expansion,generated_history,290000,3700000,3990000
|
||||
arr_move_0512,acct_0152,parent_0020,contract_0123,2025-06-15,contraction,generated_history,-300000,3800000,3500000
|
||||
arr_move_0513,acct_0153,parent_0021,contract_0124,2025-06-15,reactivation,generated_history,310000,2000000,2310000
|
||||
arr_move_0514,acct_0154,parent_0022,contract_0125,2025-06-15,new,generated_history,320000,2100000,2420000
|
||||
arr_move_0515,acct_0155,parent_0023,contract_0126,2025-06-15,expansion,generated_history,330000,2200000,2530000
|
||||
arr_move_0516,acct_0156,parent_0024,contract_0127,2025-06-15,contraction,generated_history,-340000,2300000,1960000
|
||||
arr_move_0517,acct_0157,parent_0025,contract_0128,2025-06-15,reactivation,generated_history,350000,2400000,2750000
|
||||
arr_move_0518,acct_0158,parent_0026,contract_0129,2025-06-15,new,generated_history,360000,2500000,2860000
|
||||
arr_move_0519,acct_0159,parent_0027,contract_0130,2025-06-15,expansion,generated_history,370000,2600000,2970000
|
||||
arr_move_0520,acct_0160,parent_0028,contract_0131,2025-06-15,contraction,generated_history,-250000,2700000,2450000
|
||||
arr_move_0521,acct_0161,parent_0029,contract_0132,2025-06-15,reactivation,generated_history,260000,2800000,3060000
|
||||
arr_move_0522,acct_0162,parent_0030,contract_0133,2025-06-15,new,generated_history,270000,2900000,3170000
|
||||
arr_move_0523,acct_0163,parent_0031,contract_0134,2025-06-15,expansion,generated_history,280000,3000000,3280000
|
||||
arr_move_0524,acct_0164,parent_0032,contract_0135,2025-06-15,contraction,generated_history,-290000,3100000,2810000
|
||||
arr_move_0525,acct_0165,parent_0033,contract_0136,2025-06-15,reactivation,generated_history,300000,3200000,3500000
|
||||
arr_move_0526,acct_0166,parent_0034,contract_0137,2025-06-15,new,generated_history,310000,3300000,3610000
|
||||
arr_move_0527,acct_0167,parent_0035,contract_0138,2025-06-15,expansion,generated_history,320000,3400000,3720000
|
||||
arr_move_0528,acct_0168,parent_0036,contract_0139,2025-06-15,contraction,generated_history,-330000,3500000,3170000
|
||||
arr_move_0529,acct_0169,parent_0037,contract_0140,2025-06-15,reactivation,generated_history,340000,3600000,3940000
|
||||
arr_move_0530,acct_0170,parent_0038,contract_0141,2025-06-15,new,generated_history,350000,3700000,4050000
|
||||
arr_move_0531,acct_0171,parent_0039,contract_0142,2025-06-15,expansion,generated_history,360000,3800000,4160000
|
||||
arr_move_0532,acct_0172,parent_0040,contract_0143,2025-06-15,contraction,generated_history,-370000,2000000,1630000
|
||||
arr_move_0533,acct_0173,parent_0041,contract_0144,2025-06-15,reactivation,generated_history,250000,2100000,2350000
|
||||
arr_move_0534,acct_0174,parent_0042,contract_0145,2025-06-15,new,generated_history,260000,2200000,2460000
|
||||
arr_move_0535,acct_0175,parent_0043,contract_0146,2025-06-15,expansion,generated_history,270000,2300000,2570000
|
||||
arr_move_0536,acct_0176,parent_0044,contract_0147,2025-06-15,contraction,generated_history,-280000,2400000,2120000
|
||||
arr_move_0537,acct_0177,parent_0045,contract_0148,2025-06-15,reactivation,generated_history,290000,2500000,2790000
|
||||
arr_move_0538,acct_0178,parent_0046,contract_0149,2025-06-15,new,generated_history,300000,2600000,2900000
|
||||
arr_move_0539,acct_0179,parent_0047,contract_0150,2025-06-15,expansion,generated_history,310000,2700000,3010000
|
||||
arr_move_0540,acct_0180,parent_0048,contract_0151,2025-06-15,contraction,generated_history,-320000,2800000,2480000
|
||||
arr_move_0541,acct_0181,parent_0049,contract_0152,2025-06-15,reactivation,generated_history,330000,2900000,3230000
|
||||
arr_move_0542,acct_0182,parent_0050,contract_0153,2025-06-15,new,generated_history,340000,3000000,3340000
|
||||
arr_move_0543,acct_0183,parent_0051,contract_0154,2025-06-15,expansion,generated_history,350000,3100000,3450000
|
||||
arr_move_0544,acct_0184,parent_0052,contract_0155,2025-06-15,contraction,generated_history,-360000,3200000,2840000
|
||||
arr_move_0545,acct_0185,parent_0053,contract_0156,2025-06-15,reactivation,generated_history,370000,3300000,3670000
|
||||
arr_move_0546,acct_0186,parent_0054,contract_0157,2025-06-15,new,generated_history,250000,3400000,3650000
|
||||
arr_move_0547,acct_0187,parent_0055,contract_0158,2025-06-15,expansion,generated_history,260000,3500000,3760000
|
||||
arr_move_0548,acct_0188,parent_0056,contract_0159,2025-06-15,contraction,generated_history,-270000,3600000,3330000
|
||||
arr_move_0549,acct_0189,parent_0057,contract_0160,2025-06-15,reactivation,generated_history,280000,3700000,3980000
|
||||
arr_move_0550,acct_0090,parent_0090,contract_0161,2025-06-15,new,generated_history,290000,3800000,4090000
|
||||
arr_move_0551,acct_0091,parent_0091,contract_0162,2025-06-15,expansion,generated_history,300000,2000000,2300000
|
||||
arr_move_0552,acct_0092,parent_0092,contract_0163,2025-06-15,contraction,generated_history,-310000,2100000,1790000
|
||||
arr_move_0553,acct_0093,parent_0093,contract_0164,2025-06-15,reactivation,generated_history,320000,2200000,2520000
|
||||
arr_move_0554,acct_0094,parent_0094,contract_0165,2025-06-15,new,generated_history,330000,2300000,2630000
|
||||
arr_move_0555,acct_0095,parent_0095,contract_0166,2025-06-15,expansion,generated_history,340000,2400000,2740000
|
||||
arr_move_0556,acct_0096,parent_0096,contract_0167,2025-06-15,contraction,generated_history,-350000,2500000,2150000
|
||||
arr_move_0557,acct_0097,parent_0097,contract_0168,2025-06-15,reactivation,generated_history,360000,2600000,2960000
|
||||
arr_move_0558,acct_0098,parent_0098,contract_0169,2025-06-15,new,generated_history,370000,2700000,3070000
|
||||
arr_move_0559,acct_0099,parent_0099,contract_0170,2025-06-15,expansion,generated_history,250000,2800000,3050000
|
||||
arr_move_0560,acct_0100,parent_0100,contract_0171,2025-06-15,contraction,generated_history,-260000,2900000,2640000
|
||||
arr_move_0561,acct_0101,parent_0101,contract_0172,2025-06-15,reactivation,generated_history,270000,3000000,3270000
|
||||
arr_move_0562,acct_0102,parent_0102,contract_0173,2025-06-15,new,generated_history,280000,3100000,3380000
|
||||
arr_move_0563,acct_0103,parent_0103,contract_0174,2025-06-15,expansion,generated_history,290000,3200000,3490000
|
||||
arr_move_0564,acct_0104,parent_0104,contract_0175,2025-06-15,contraction,generated_history,-300000,3300000,3000000
|
||||
arr_move_0565,acct_0105,parent_0105,contract_0176,2025-06-15,reactivation,generated_history,310000,3400000,3710000
|
||||
arr_move_0566,acct_0106,parent_0106,contract_0177,2025-06-15,new,generated_history,320000,3500000,3820000
|
||||
arr_move_0567,acct_0107,parent_0107,contract_0178,2025-06-15,expansion,generated_history,330000,3600000,3930000
|
||||
arr_move_0568,acct_0108,parent_0108,contract_0179,2025-06-15,contraction,generated_history,-340000,3700000,3360000
|
||||
arr_move_0569,acct_0109,parent_0109,contract_0180,2025-06-15,reactivation,generated_history,350000,3800000,4150000
|
||||
arr_move_0570,acct_0110,parent_0110,contract_0181,2025-06-15,new,generated_history,360000,2000000,2360000
|
||||
arr_move_0571,acct_0111,parent_0111,contract_0182,2025-06-15,expansion,generated_history,370000,2100000,2470000
|
||||
arr_move_0572,acct_0112,parent_0112,contract_0183,2025-06-15,contraction,generated_history,-250000,2200000,1950000
|
||||
arr_move_0573,acct_0113,parent_0113,contract_0184,2025-06-15,reactivation,generated_history,260000,2300000,2560000
|
||||
arr_move_0574,acct_0114,parent_0114,contract_0185,2025-06-15,new,generated_history,270000,2400000,2670000
|
||||
arr_move_0575,acct_0115,parent_0115,contract_0186,2025-06-15,expansion,generated_history,280000,2500000,2780000
|
||||
arr_move_0576,acct_0116,parent_0116,contract_0187,2025-06-15,contraction,generated_history,-290000,2600000,2310000
|
||||
arr_move_0577,acct_0117,parent_0117,contract_0188,2025-06-15,reactivation,generated_history,300000,2700000,3000000
|
||||
arr_move_0578,acct_0118,parent_0118,contract_0189,2025-06-15,new,generated_history,310000,2800000,3110000
|
||||
arr_move_0579,acct_0119,parent_0119,contract_0190,2025-06-15,expansion,generated_history,320000,2900000,3220000
|
||||
arr_move_0580,acct_0120,parent_0120,contract_0191,2025-06-15,contraction,generated_history,-330000,3000000,2670000
|
||||
arr_move_0581,acct_0121,parent_0121,contract_0192,2025-06-15,reactivation,generated_history,340000,3100000,3440000
|
||||
arr_move_0582,acct_0122,parent_0122,contract_0193,2025-06-15,new,generated_history,350000,3200000,3550000
|
||||
arr_move_0583,acct_0123,parent_0123,contract_0194,2025-06-15,expansion,generated_history,360000,3300000,3660000
|
||||
arr_move_0584,acct_0124,parent_0124,contract_0195,2025-06-15,contraction,generated_history,-370000,3400000,3030000
|
||||
arr_move_0585,acct_0125,parent_0125,contract_0196,2025-06-15,reactivation,generated_history,250000,3500000,3750000
|
||||
arr_move_0586,acct_0126,parent_0126,contract_0197,2025-06-15,new,generated_history,260000,3600000,3860000
|
||||
arr_move_0587,acct_0127,parent_0127,contract_0198,2025-06-15,expansion,generated_history,270000,3700000,3970000
|
||||
arr_move_0588,acct_0128,parent_0128,contract_0199,2025-06-15,contraction,generated_history,-280000,3800000,3520000
|
||||
arr_move_0589,acct_0129,parent_0129,contract_0200,2025-06-15,reactivation,generated_history,290000,2000000,2290000
|
||||
arr_move_0590,acct_0130,parent_0130,contract_0201,2025-06-15,new,generated_history,300000,2100000,2400000
|
||||
arr_move_0591,acct_0131,parent_0131,contract_0202,2025-06-15,expansion,generated_history,310000,2200000,2510000
|
||||
arr_move_0592,acct_0132,parent_0132,contract_0203,2025-06-15,contraction,generated_history,-320000,2300000,1980000
|
||||
arr_move_0593,acct_0133,parent_0001,contract_0204,2025-06-15,reactivation,generated_history,330000,2400000,2730000
|
||||
arr_move_0594,acct_0134,parent_0002,contract_0205,2025-06-15,new,generated_history,340000,2500000,2840000
|
||||
arr_move_0595,acct_0135,parent_0003,contract_0206,2025-06-15,expansion,generated_history,350000,2600000,2950000
|
||||
arr_move_0596,acct_0136,parent_0004,contract_0207,2025-06-15,contraction,generated_history,-360000,2700000,2340000
|
||||
arr_move_0597,acct_0137,parent_0005,contract_0208,2025-06-15,reactivation,generated_history,370000,2800000,3170000
|
||||
arr_move_0598,acct_0138,parent_0006,contract_0209,2025-06-15,new,generated_history,250000,2900000,3150000
|
||||
arr_move_0599,acct_0139,parent_0007,contract_0210,2025-06-15,expansion,generated_history,260000,3000000,3260000
|
||||
arr_move_0600,acct_0140,parent_0008,contract_0211,2025-06-15,contraction,generated_history,-270000,3100000,2830000
|
||||
arr_move_0601,acct_0141,parent_0009,contract_0212,2025-06-15,reactivation,generated_history,280000,3200000,3480000
|
||||
arr_move_0602,acct_0142,parent_0010,contract_0213,2025-06-15,new,generated_history,290000,3300000,3590000
|
||||
arr_move_0603,acct_0143,parent_0011,contract_0214,2025-06-15,expansion,generated_history,300000,3400000,3700000
|
||||
arr_move_0604,acct_0144,parent_0012,contract_0215,2025-06-15,contraction,generated_history,-310000,3500000,3190000
|
||||
arr_move_0605,acct_0145,parent_0013,contract_0216,2025-06-15,reactivation,generated_history,320000,3600000,3920000
|
||||
arr_move_0606,acct_0146,parent_0014,contract_0217,2025-06-15,new,generated_history,330000,3700000,4030000
|
||||
arr_move_0607,acct_0147,parent_0015,contract_0218,2025-06-15,expansion,generated_history,340000,3800000,4140000
|
||||
arr_move_0608,acct_0148,parent_0016,contract_0219,2025-06-15,contraction,generated_history,-350000,2000000,1650000
|
||||
arr_move_0609,acct_0149,parent_0017,contract_0220,2025-06-15,reactivation,generated_history,360000,2100000,2460000
|
||||
arr_move_0610,acct_0150,parent_0018,contract_0221,2025-06-15,new,generated_history,370000,2200000,2570000
|
||||
arr_move_0611,acct_0151,parent_0019,contract_0222,2025-06-15,expansion,generated_history,250000,2300000,2550000
|
||||
arr_move_0612,acct_0152,parent_0020,contract_0223,2025-06-15,contraction,generated_history,-260000,2400000,2140000
|
||||
arr_move_0613,acct_0153,parent_0021,contract_0224,2025-06-15,reactivation,generated_history,270000,2500000,2770000
|
||||
arr_move_0614,acct_0154,parent_0022,contract_0225,2025-06-15,new,generated_history,280000,2600000,2880000
|
||||
arr_move_0615,acct_0155,parent_0023,contract_0226,2025-06-15,expansion,generated_history,290000,2700000,2990000
|
||||
arr_move_0616,acct_0156,parent_0024,contract_0227,2025-06-15,contraction,generated_history,-300000,2800000,2500000
|
||||
arr_move_0617,acct_0157,parent_0025,contract_0228,2025-06-15,reactivation,generated_history,310000,2900000,3210000
|
||||
arr_move_0618,acct_0158,parent_0026,contract_0229,2025-06-15,new,generated_history,320000,3000000,3320000
|
||||
arr_move_0619,acct_0159,parent_0027,contract_0230,2025-06-15,expansion,generated_history,330000,3100000,3430000
|
||||
arr_move_0620,acct_0160,parent_0028,contract_0231,2025-06-15,contraction,generated_history,-340000,3200000,2860000
|
||||
arr_move_0621,acct_0161,parent_0029,contract_0232,2025-06-15,reactivation,generated_history,350000,3300000,3650000
|
||||
arr_move_0622,acct_0162,parent_0030,contract_0233,2025-06-15,new,generated_history,360000,3400000,3760000
|
||||
arr_move_0623,acct_0163,parent_0031,contract_0234,2025-06-15,expansion,generated_history,370000,3500000,3870000
|
||||
arr_move_0624,acct_0164,parent_0032,contract_0235,2025-06-15,contraction,generated_history,-250000,3600000,3350000
|
||||
arr_move_0625,acct_0165,parent_0033,contract_0236,2025-06-15,reactivation,generated_history,260000,3700000,3960000
|
||||
arr_move_0626,acct_0166,parent_0034,contract_0237,2025-06-15,new,generated_history,270000,3800000,4070000
|
||||
arr_move_0627,acct_0167,parent_0035,contract_0238,2025-06-15,expansion,generated_history,280000,2000000,2280000
|
||||
arr_move_0628,acct_0168,parent_0036,contract_0239,2025-06-15,contraction,generated_history,-290000,2100000,1810000
|
||||
arr_move_0629,acct_0169,parent_0037,contract_0240,2025-06-15,reactivation,generated_history,300000,2200000,2500000
|
||||
arr_move_0630,acct_0170,parent_0038,contract_0241,2025-06-15,new,generated_history,310000,2300000,2610000
|
||||
arr_move_0631,acct_0171,parent_0039,contract_0242,2025-06-15,expansion,generated_history,320000,2400000,2720000
|
||||
arr_move_0632,acct_0172,parent_0040,contract_0243,2025-06-15,contraction,generated_history,-330000,2500000,2170000
|
||||
arr_move_0633,acct_0173,parent_0041,contract_0244,2025-06-15,reactivation,generated_history,340000,2600000,2940000
|
||||
arr_move_0634,acct_0174,parent_0042,contract_0245,2025-06-15,new,generated_history,350000,2700000,3050000
|
||||
arr_move_0635,acct_0175,parent_0043,contract_0246,2025-06-15,expansion,generated_history,360000,2800000,3160000
|
||||
arr_move_0636,acct_0176,parent_0044,contract_0247,2025-06-15,contraction,generated_history,-370000,2900000,2530000
|
||||
arr_move_0637,acct_0177,parent_0045,contract_0248,2025-06-15,reactivation,generated_history,250000,3000000,3250000
|
||||
arr_move_0638,acct_0178,parent_0046,contract_0249,2025-06-15,new,generated_history,260000,3100000,3360000
|
||||
arr_move_0639,acct_0179,parent_0047,contract_0250,2025-06-15,expansion,generated_history,270000,3200000,3470000
|
||||
arr_move_0640,acct_0180,parent_0048,contract_0251,2025-06-15,contraction,generated_history,-280000,3300000,3020000
|
||||
arr_move_0641,acct_0181,parent_0049,contract_0252,2025-06-15,reactivation,generated_history,290000,3400000,3690000
|
||||
arr_move_0642,acct_0182,parent_0050,contract_0253,2025-06-15,new,generated_history,300000,3500000,3800000
|
||||
arr_move_0643,acct_0183,parent_0051,contract_0254,2025-06-15,expansion,generated_history,310000,3600000,3910000
|
||||
arr_move_0644,acct_0184,parent_0052,contract_0255,2025-06-15,contraction,generated_history,-320000,3700000,3380000
|
||||
arr_move_0645,acct_0185,parent_0053,contract_0256,2025-06-15,reactivation,generated_history,330000,3800000,4130000
|
||||
arr_move_0646,acct_0186,parent_0054,contract_0257,2025-06-15,new,generated_history,340000,2000000,2340000
|
||||
arr_move_0647,acct_0187,parent_0055,contract_0258,2025-06-15,expansion,generated_history,350000,2100000,2450000
|
||||
arr_move_0648,acct_0188,parent_0056,contract_0259,2025-06-15,contraction,generated_history,-360000,2200000,1840000
|
||||
arr_move_0649,acct_0189,parent_0057,contract_0260,2025-06-15,reactivation,generated_history,370000,2300000,2670000
|
||||
arr_move_0650,acct_0090,parent_0090,contract_0261,2025-06-15,new,generated_history,250000,2400000,2650000
|
||||
arr_move_0651,acct_0091,parent_0091,contract_0262,2025-06-15,expansion,generated_history,260000,2500000,2760000
|
||||
arr_move_0652,acct_0092,parent_0092,contract_0263,2025-06-15,contraction,generated_history,-270000,2600000,2330000
|
||||
arr_move_0653,acct_0093,parent_0093,contract_0264,2025-06-15,reactivation,generated_history,280000,2700000,2980000
|
||||
arr_move_0654,acct_0094,parent_0094,contract_0265,2025-06-15,new,generated_history,290000,2800000,3090000
|
||||
arr_move_0655,acct_0095,parent_0095,contract_0266,2025-06-15,expansion,generated_history,300000,2900000,3200000
|
||||
arr_move_0656,acct_0096,parent_0096,contract_0267,2025-06-15,contraction,generated_history,-310000,3000000,2690000
|
||||
arr_move_0657,acct_0097,parent_0097,contract_0268,2025-06-15,reactivation,generated_history,320000,3100000,3420000
|
||||
arr_move_0658,acct_0098,parent_0098,contract_0269,2025-06-15,new,generated_history,330000,3200000,3530000
|
||||
arr_move_0659,acct_0099,parent_0099,contract_0270,2025-06-15,expansion,generated_history,340000,3300000,3640000
|
||||
arr_move_0660,acct_0100,parent_0100,contract_0271,2025-06-15,contraction,generated_history,-350000,3400000,3050000
|
||||
arr_move_0661,acct_0101,parent_0101,contract_0272,2025-06-15,reactivation,generated_history,360000,3500000,3860000
|
||||
arr_move_0662,acct_0102,parent_0102,contract_0273,2025-06-15,new,generated_history,370000,3600000,3970000
|
||||
arr_move_0663,acct_0103,parent_0103,contract_0274,2025-06-15,expansion,generated_history,250000,3700000,3950000
|
||||
arr_move_0664,acct_0104,parent_0104,contract_0275,2025-06-15,contraction,generated_history,-260000,3800000,3540000
|
||||
arr_move_0665,acct_0105,parent_0105,contract_0276,2025-06-15,reactivation,generated_history,270000,2000000,2270000
|
||||
arr_move_0666,acct_0106,parent_0106,contract_0277,2025-06-15,new,generated_history,280000,2100000,2380000
|
||||
arr_move_0667,acct_0107,parent_0107,contract_0278,2025-06-15,expansion,generated_history,290000,2200000,2490000
|
||||
arr_move_0668,acct_0108,parent_0108,contract_0279,2025-06-15,contraction,generated_history,-300000,2300000,2000000
|
||||
arr_move_0669,acct_0109,parent_0109,contract_0280,2025-06-15,reactivation,generated_history,310000,2400000,2710000
|
||||
arr_move_0670,acct_0110,parent_0110,contract_0281,2025-06-15,new,generated_history,320000,2500000,2820000
|
||||
arr_move_0671,acct_0111,parent_0111,contract_0282,2025-06-15,expansion,generated_history,330000,2600000,2930000
|
||||
arr_move_0672,acct_0112,parent_0112,contract_0283,2025-06-15,contraction,generated_history,-340000,2700000,2360000
|
||||
arr_move_0673,acct_0113,parent_0113,contract_0284,2025-06-15,reactivation,generated_history,350000,2800000,3150000
|
||||
arr_move_0674,acct_0114,parent_0114,contract_0285,2025-06-15,new,generated_history,360000,2900000,3260000
|
||||
arr_move_0675,acct_0115,parent_0115,contract_0286,2025-06-15,expansion,generated_history,370000,3000000,3370000
|
||||
arr_move_0676,acct_0116,parent_0116,contract_0287,2025-06-15,contraction,generated_history,-250000,3100000,2850000
|
||||
arr_move_0677,acct_0117,parent_0117,contract_0288,2025-06-15,reactivation,generated_history,260000,3200000,3460000
|
||||
arr_move_0678,acct_0118,parent_0118,contract_0289,2025-06-15,new,generated_history,270000,3300000,3570000
|
||||
arr_move_0679,acct_0119,parent_0119,contract_0290,2025-06-15,expansion,generated_history,280000,3400000,3680000
|
||||
arr_move_0680,acct_0120,parent_0120,contract_0291,2025-06-15,contraction,generated_history,-290000,3500000,3210000
|
||||
arr_move_0681,acct_0121,parent_0121,contract_0292,2025-06-15,reactivation,generated_history,300000,3600000,3900000
|
||||
arr_move_0682,acct_0122,parent_0122,contract_0293,2025-06-15,new,generated_history,310000,3700000,4010000
|
||||
arr_move_0683,acct_0123,parent_0123,contract_0294,2025-06-15,expansion,generated_history,320000,3800000,4120000
|
||||
arr_move_0684,acct_0124,parent_0124,contract_0295,2025-06-15,contraction,generated_history,-330000,2000000,1670000
|
||||
arr_move_0685,acct_0125,parent_0125,contract_0296,2025-06-15,reactivation,generated_history,340000,2100000,2440000
|
||||
arr_move_0686,acct_0126,parent_0126,contract_0297,2025-06-15,new,generated_history,350000,2200000,2550000
|
||||
arr_move_0687,acct_0127,parent_0127,contract_0298,2025-06-15,expansion,generated_history,360000,2300000,2660000
|
||||
arr_move_0688,acct_0128,parent_0128,contract_0299,2025-06-15,contraction,generated_history,-370000,2400000,2030000
|
||||
arr_move_0689,acct_0129,parent_0129,contract_0300,2025-06-15,reactivation,generated_history,250000,2500000,2750000
|
||||
arr_move_0690,acct_0130,parent_0130,contract_0301,2025-06-15,new,generated_history,260000,2600000,2860000
|
||||
arr_move_0691,acct_0131,parent_0131,contract_0302,2025-06-15,expansion,generated_history,270000,2700000,2970000
|
||||
arr_move_0692,acct_0132,parent_0132,contract_0303,2025-06-15,contraction,generated_history,-280000,2800000,2520000
|
||||
arr_move_0693,acct_0133,parent_0001,contract_0304,2025-06-15,reactivation,generated_history,290000,2900000,3190000
|
||||
arr_move_0694,acct_0134,parent_0002,contract_0305,2025-06-15,new,generated_history,300000,3000000,3300000
|
||||
arr_move_0695,acct_0135,parent_0003,contract_0306,2025-06-15,expansion,generated_history,310000,3100000,3410000
|
||||
arr_move_0696,acct_0136,parent_0004,contract_0307,2025-06-15,contraction,generated_history,-320000,3200000,2880000
|
||||
arr_move_0697,acct_0137,parent_0005,contract_0308,2025-06-15,reactivation,generated_history,330000,3300000,3630000
|
||||
arr_move_0698,acct_0138,parent_0006,contract_0309,2025-06-15,new,generated_history,340000,3400000,3740000
|
||||
arr_move_0699,acct_0139,parent_0007,contract_0310,2025-06-15,expansion,generated_history,350000,3500000,3850000
|
||||
arr_move_0700,acct_0140,parent_0008,contract_0311,2025-06-15,contraction,generated_history,-360000,3600000,3240000
|
||||
arr_move_0701,acct_0141,parent_0009,contract_0312,2025-06-15,reactivation,generated_history,370000,3700000,4070000
|
||||
arr_move_0702,acct_0142,parent_0010,contract_0313,2025-06-15,new,generated_history,250000,3800000,4050000
|
||||
arr_move_0703,acct_0143,parent_0011,contract_0314,2025-06-15,expansion,generated_history,260000,2000000,2260000
|
||||
arr_move_0704,acct_0144,parent_0012,contract_0315,2025-06-15,contraction,generated_history,-270000,2100000,1830000
|
||||
arr_move_0705,acct_0145,parent_0013,contract_0316,2025-06-15,reactivation,generated_history,280000,2200000,2480000
|
||||
arr_move_0706,acct_0146,parent_0014,contract_0317,2025-06-15,new,generated_history,290000,2300000,2590000
|
||||
arr_move_0707,acct_0147,parent_0015,contract_0318,2025-06-15,expansion,generated_history,300000,2400000,2700000
|
||||
arr_move_0708,acct_0148,parent_0016,contract_0319,2025-06-15,contraction,generated_history,-310000,2500000,2190000
|
||||
arr_move_0709,acct_0149,parent_0017,contract_0320,2025-06-15,reactivation,generated_history,320000,2600000,2920000
|
||||
arr_move_0710,acct_0150,parent_0018,contract_0101,2025-06-15,new,generated_history,330000,2700000,3030000
|
||||
arr_move_0711,acct_0151,parent_0019,contract_0102,2025-06-15,expansion,generated_history,340000,2800000,3140000
|
||||
arr_move_0712,acct_0152,parent_0020,contract_0103,2025-06-15,contraction,generated_history,-350000,2900000,2550000
|
||||
arr_move_0713,acct_0153,parent_0021,contract_0104,2025-06-15,reactivation,generated_history,360000,3000000,3360000
|
||||
arr_move_0714,acct_0154,parent_0022,contract_0105,2025-06-15,new,generated_history,370000,3100000,3470000
|
||||
arr_move_0715,acct_0155,parent_0023,contract_0106,2025-06-15,expansion,generated_history,250000,3200000,3450000
|
||||
arr_move_0716,acct_0156,parent_0024,contract_0107,2025-06-15,contraction,generated_history,-260000,3300000,3040000
|
||||
arr_move_0717,acct_0157,parent_0025,contract_0108,2025-06-15,reactivation,generated_history,270000,3400000,3670000
|
||||
arr_move_0718,acct_0158,parent_0026,contract_0109,2025-06-15,new,generated_history,280000,3500000,3780000
|
||||
arr_move_0719,acct_0159,parent_0027,contract_0110,2025-06-15,expansion,generated_history,290000,3600000,3890000
|
||||
arr_move_0720,acct_0160,parent_0028,contract_0111,2025-06-15,contraction,generated_history,-300000,3700000,3400000
|
||||
|
|
|
@ -0,0 +1,321 @@
|
|||
contract_id,account_id,parent_account_id,plan_id,contract_arr_cents,booked_arr_cents,start_date,end_date,status,renewal_type
|
||||
contract_0001,acct_0001,parent_0001,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0002,acct_0002,parent_0002,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0003,acct_0003,parent_0003,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0004,acct_0004,parent_0004,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0005,acct_0005,parent_0005,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0006,acct_0006,parent_0006,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0007,acct_0007,parent_0007,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0008,acct_0008,parent_0008,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0009,acct_0009,parent_0009,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0010,acct_0010,parent_0010,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0011,acct_0011,parent_0011,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0012,acct_0012,parent_0012,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0013,acct_0013,parent_0013,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0014,acct_0014,parent_0014,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0015,acct_0015,parent_0015,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0016,acct_0016,parent_0016,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0017,acct_0017,parent_0017,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0018,acct_0018,parent_0018,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0019,acct_0019,parent_0019,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0020,acct_0020,parent_0020,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0021,acct_0021,parent_0021,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0022,acct_0022,parent_0022,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0023,acct_0023,parent_0023,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0024,acct_0024,parent_0024,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0025,acct_0025,parent_0025,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0026,acct_0026,parent_0026,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0027,acct_0027,parent_0027,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0028,acct_0028,parent_0028,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0029,acct_0029,parent_0029,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0030,acct_0030,parent_0030,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0031,acct_0031,parent_0031,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0032,acct_0032,parent_0032,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0033,acct_0033,parent_0033,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0034,acct_0034,parent_0034,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0035,acct_0035,parent_0035,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0036,acct_0036,parent_0036,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0037,acct_0037,parent_0037,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0038,acct_0038,parent_0038,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0039,acct_0039,parent_0039,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0040,acct_0040,parent_0040,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0041,acct_0041,parent_0041,plan_004,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0042,acct_0042,parent_0042,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0043,acct_0043,parent_0043,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0044,acct_0044,parent_0044,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0045,acct_0045,parent_0045,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0046,acct_0046,parent_0046,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0047,acct_0047,parent_0047,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0048,acct_0048,parent_0048,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0049,acct_0049,parent_0049,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0050,acct_0050,parent_0050,plan_003,25000000,25000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0051,acct_0051,parent_0051,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0052,acct_0052,parent_0052,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0053,acct_0053,parent_0053,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0054,acct_0054,parent_0054,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0055,acct_0055,parent_0055,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0056,acct_0056,parent_0056,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0057,acct_0057,parent_0057,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0058,acct_0058,parent_0058,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0059,acct_0059,parent_0059,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0060,acct_0060,parent_0060,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0061,acct_0061,parent_0061,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0062,acct_0062,parent_0062,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0063,acct_0063,parent_0063,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0064,acct_0064,parent_0064,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0065,acct_0065,parent_0065,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0066,acct_0066,parent_0066,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0067,acct_0067,parent_0067,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0068,acct_0068,parent_0068,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0069,acct_0069,parent_0069,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0070,acct_0070,parent_0070,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0071,acct_0071,parent_0071,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0072,acct_0072,parent_0072,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0073,acct_0073,parent_0073,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0074,acct_0074,parent_0074,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0075,acct_0075,parent_0075,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0076,acct_0076,parent_0076,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0077,acct_0077,parent_0077,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0078,acct_0078,parent_0078,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0079,acct_0079,parent_0079,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0080,acct_0080,parent_0080,plan_003,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0081,acct_0081,parent_0081,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0082,acct_0082,parent_0082,plan_004,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0083,acct_0083,parent_0083,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0084,acct_0084,parent_0084,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0085,acct_0085,parent_0085,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0086,acct_0086,parent_0086,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0087,acct_0087,parent_0087,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0088,acct_0088,parent_0088,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0089,acct_0089,parent_0089,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0090,acct_0090,parent_0090,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0091,acct_0091,parent_0091,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0092,acct_0092,parent_0092,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0093,acct_0093,parent_0093,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0094,acct_0094,parent_0094,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0095,acct_0095,parent_0095,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0096,acct_0096,parent_0096,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0097,acct_0097,parent_0097,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,new
|
||||
contract_0098,acct_0098,parent_0098,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,renewal
|
||||
contract_0099,acct_0099,parent_0099,plan_002,12000000,12000000,2025-01-01,2026-12-31,active,expansion
|
||||
contract_0100,acct_0100,parent_0100,plan_002,36200000,36200000,2025-01-01,2026-12-31,active,downgrade
|
||||
contract_0101,acct_0101,parent_0101,plan_002,4010000,4010000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0102,acct_0102,parent_0102,plan_002,4020000,4020000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0103,acct_0103,parent_0103,plan_002,4030000,4030000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0104,acct_0104,parent_0104,plan_002,4040000,4040000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0105,acct_0105,parent_0105,plan_002,4050000,4050000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0106,acct_0106,parent_0106,plan_002,4060000,4060000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0107,acct_0107,parent_0107,plan_002,4070000,4070000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0108,acct_0108,parent_0108,plan_002,4080000,4080000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0109,acct_0109,parent_0109,plan_002,4090000,4090000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0110,acct_0110,parent_0110,plan_002,4100000,4100000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0111,acct_0111,parent_0111,plan_002,4110000,4110000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0112,acct_0112,parent_0112,plan_002,4120000,4120000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0113,acct_0113,parent_0113,plan_002,4130000,4130000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0114,acct_0114,parent_0114,plan_002,4140000,4140000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0115,acct_0115,parent_0115,plan_002,4150000,4150000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0116,acct_0116,parent_0116,plan_002,4160000,4160000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0117,acct_0117,parent_0117,plan_002,4170000,4170000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0118,acct_0118,parent_0118,plan_002,4180000,4180000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0119,acct_0119,parent_0119,plan_002,4190000,4190000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0120,acct_0120,parent_0120,plan_002,4200000,4200000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0121,acct_0121,parent_0121,plan_002,4210000,4210000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0122,acct_0122,parent_0122,plan_002,4220000,4220000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0123,acct_0123,parent_0123,plan_004,4230000,4230000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0124,acct_0124,parent_0124,plan_002,4240000,4240000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0125,acct_0125,parent_0125,plan_002,4250000,4250000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0126,acct_0126,parent_0126,plan_002,4260000,4260000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0127,acct_0127,parent_0127,plan_002,4270000,4270000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0128,acct_0128,parent_0128,plan_002,4280000,4280000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0129,acct_0129,parent_0129,plan_002,4290000,4290000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0130,acct_0130,parent_0130,plan_002,4300000,4300000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0131,acct_0131,parent_0131,plan_002,4310000,4310000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0132,acct_0132,parent_0132,plan_002,4320000,4320000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0133,acct_0133,parent_0001,plan_002,4330000,4330000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0134,acct_0134,parent_0002,plan_002,4340000,4340000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0135,acct_0135,parent_0003,plan_002,4350000,4350000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0136,acct_0136,parent_0004,plan_002,4360000,4360000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0137,acct_0137,parent_0005,plan_002,4370000,4370000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0138,acct_0138,parent_0006,plan_002,4380000,4380000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0139,acct_0139,parent_0007,plan_002,4390000,4390000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0140,acct_0140,parent_0008,plan_002,4400000,4400000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0141,acct_0141,parent_0009,plan_002,4410000,4410000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0142,acct_0142,parent_0010,plan_002,4420000,4420000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0143,acct_0143,parent_0011,plan_002,4430000,4430000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0144,acct_0144,parent_0012,plan_002,4440000,4440000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0145,acct_0145,parent_0013,plan_002,4450000,4450000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0146,acct_0146,parent_0014,plan_002,4460000,4460000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0147,acct_0147,parent_0015,plan_002,4470000,4470000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0148,acct_0148,parent_0016,plan_002,4480000,4480000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0149,acct_0149,parent_0017,plan_002,4490000,4490000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0150,acct_0150,parent_0018,plan_002,4500000,4500000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0151,acct_0151,parent_0019,plan_001,4510000,4510000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0152,acct_0152,parent_0020,plan_001,4520000,4520000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0153,acct_0153,parent_0021,plan_001,4530000,4530000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0154,acct_0154,parent_0022,plan_001,4540000,4540000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0155,acct_0155,parent_0023,plan_001,4550000,4550000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0156,acct_0156,parent_0024,plan_001,4560000,4560000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0157,acct_0157,parent_0025,plan_001,4570000,4570000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0158,acct_0158,parent_0026,plan_001,4580000,4580000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0159,acct_0159,parent_0027,plan_001,4590000,4590000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0160,acct_0160,parent_0028,plan_001,4600000,4600000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0161,acct_0161,parent_0029,plan_001,4610000,4610000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0162,acct_0162,parent_0030,plan_001,4620000,4620000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0163,acct_0163,parent_0031,plan_001,4630000,4630000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0164,acct_0164,parent_0032,plan_004,4640000,4640000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0165,acct_0165,parent_0033,plan_001,4650000,4650000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0166,acct_0166,parent_0034,plan_001,4660000,4660000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0167,acct_0167,parent_0035,plan_001,4670000,4670000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0168,acct_0168,parent_0036,plan_001,4680000,4680000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0169,acct_0169,parent_0037,plan_001,4690000,4690000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0170,acct_0170,parent_0038,plan_001,4700000,4700000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0171,acct_0171,parent_0039,plan_001,4710000,4710000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0172,acct_0172,parent_0040,plan_001,4720000,4720000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0173,acct_0173,parent_0041,plan_001,4730000,4730000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0174,acct_0174,parent_0042,plan_001,4740000,4740000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0175,acct_0175,parent_0043,plan_001,4750000,4750000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0176,acct_0176,parent_0044,plan_001,4760000,4760000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0177,acct_0177,parent_0045,plan_001,4770000,4770000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0178,acct_0178,parent_0046,plan_001,4780000,4780000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0179,acct_0179,parent_0047,plan_001,4790000,4790000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0180,acct_0180,parent_0048,plan_001,4800000,4800000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0181,acct_0181,parent_0049,plan_001,4810000,4810000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0182,acct_0182,parent_0050,plan_001,4820000,4820000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0183,acct_0183,parent_0051,plan_001,4830000,4830000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0184,acct_0184,parent_0052,plan_001,4840000,4840000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0185,acct_0185,parent_0053,plan_001,4850000,4850000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0186,acct_0186,parent_0054,plan_001,4860000,4860000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0187,acct_0187,parent_0055,plan_001,4870000,4870000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0188,acct_0188,parent_0056,plan_001,4880000,4880000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0189,acct_0189,parent_0057,plan_001,4890000,4890000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0190,acct_0190,parent_0058,plan_001,4900000,4900000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0191,acct_0191,parent_0059,plan_001,4910000,4910000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0192,acct_0192,parent_0060,plan_001,4920000,4920000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0193,acct_0193,parent_0061,plan_001,4930000,4930000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0194,acct_0194,parent_0062,plan_001,4940000,4940000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0195,acct_0195,parent_0063,plan_001,4950000,4950000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0196,acct_0196,parent_0064,plan_001,4960000,4960000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0197,acct_0197,parent_0065,plan_001,4970000,4970000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0198,acct_0198,parent_0066,plan_001,4980000,4980000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0199,acct_0199,parent_0067,plan_001,4990000,4990000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0200,acct_0200,parent_0068,plan_001,5000000,5000000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0201,acct_0001,parent_0001,plan_003,5010000,5010000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0202,acct_0002,parent_0002,plan_003,5020000,5020000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0203,acct_0003,parent_0003,plan_003,5030000,5030000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0204,acct_0004,parent_0004,plan_003,5040000,5040000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0205,acct_0005,parent_0005,plan_004,5050000,5050000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0206,acct_0006,parent_0006,plan_003,5060000,5060000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0207,acct_0007,parent_0007,plan_003,5070000,5070000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0208,acct_0008,parent_0008,plan_003,5080000,5080000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0209,acct_0009,parent_0009,plan_003,5090000,5090000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0210,acct_0010,parent_0010,plan_003,5100000,5100000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0211,acct_0011,parent_0011,plan_003,5110000,5110000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0212,acct_0012,parent_0012,plan_003,5120000,5120000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0213,acct_0013,parent_0013,plan_003,5130000,5130000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0214,acct_0014,parent_0014,plan_003,5140000,5140000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0215,acct_0015,parent_0015,plan_003,5150000,5150000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0216,acct_0016,parent_0016,plan_003,5160000,5160000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0217,acct_0017,parent_0017,plan_003,5170000,5170000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0218,acct_0018,parent_0018,plan_003,5180000,5180000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0219,acct_0019,parent_0019,plan_003,5190000,5190000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0220,acct_0020,parent_0020,plan_003,5200000,5200000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0221,acct_0021,parent_0021,plan_003,5210000,5210000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0222,acct_0022,parent_0022,plan_003,5220000,5220000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0223,acct_0023,parent_0023,plan_003,5230000,5230000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0224,acct_0024,parent_0024,plan_003,5240000,5240000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0225,acct_0025,parent_0025,plan_003,5250000,5250000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0226,acct_0026,parent_0026,plan_003,5260000,5260000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0227,acct_0027,parent_0027,plan_003,5270000,5270000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0228,acct_0028,parent_0028,plan_003,5280000,5280000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0229,acct_0029,parent_0029,plan_003,5290000,5290000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0230,acct_0030,parent_0030,plan_003,5300000,5300000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0231,acct_0031,parent_0031,plan_003,5310000,5310000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0232,acct_0032,parent_0032,plan_003,5320000,5320000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0233,acct_0033,parent_0033,plan_003,5330000,5330000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0234,acct_0034,parent_0034,plan_003,5340000,5340000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0235,acct_0035,parent_0035,plan_003,5350000,5350000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0236,acct_0036,parent_0036,plan_003,5360000,5360000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0237,acct_0037,parent_0037,plan_003,5370000,5370000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0238,acct_0038,parent_0038,plan_003,5380000,5380000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0239,acct_0039,parent_0039,plan_003,5390000,5390000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0240,acct_0040,parent_0040,plan_003,5400000,5400000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0241,acct_0041,parent_0041,plan_003,5410000,5410000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0242,acct_0042,parent_0042,plan_003,5420000,5420000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0243,acct_0043,parent_0043,plan_003,5430000,5430000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0244,acct_0044,parent_0044,plan_003,5440000,5440000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0245,acct_0045,parent_0045,plan_003,5450000,5450000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0246,acct_0046,parent_0046,plan_004,5460000,5460000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0247,acct_0047,parent_0047,plan_003,5470000,5470000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0248,acct_0048,parent_0048,plan_003,5480000,5480000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0249,acct_0049,parent_0049,plan_003,5490000,5490000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0250,acct_0050,parent_0050,plan_003,5500000,5500000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0251,acct_0051,parent_0051,plan_003,5510000,5510000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0252,acct_0052,parent_0052,plan_003,5520000,5520000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0253,acct_0053,parent_0053,plan_003,5530000,5530000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0254,acct_0054,parent_0054,plan_003,5540000,5540000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0255,acct_0055,parent_0055,plan_003,5550000,5550000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0256,acct_0056,parent_0056,plan_003,5560000,5560000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0257,acct_0057,parent_0057,plan_003,5570000,5570000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0258,acct_0058,parent_0058,plan_003,5580000,5580000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0259,acct_0059,parent_0059,plan_003,5590000,5590000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0260,acct_0060,parent_0060,plan_003,5600000,5600000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0261,acct_0061,parent_0061,plan_003,5610000,5610000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0262,acct_0062,parent_0062,plan_003,5620000,5620000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0263,acct_0063,parent_0063,plan_003,5630000,5630000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0264,acct_0064,parent_0064,plan_003,5640000,5640000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0265,acct_0065,parent_0065,plan_003,5650000,5650000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0266,acct_0066,parent_0066,plan_003,5660000,5660000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0267,acct_0067,parent_0067,plan_003,5670000,5670000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0268,acct_0068,parent_0068,plan_003,5680000,5680000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0269,acct_0069,parent_0069,plan_003,5690000,5690000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0270,acct_0070,parent_0070,plan_003,5700000,5700000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0271,acct_0071,parent_0071,plan_003,5710000,5710000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0272,acct_0072,parent_0072,plan_003,5720000,5720000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0273,acct_0073,parent_0073,plan_003,5730000,5730000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0274,acct_0074,parent_0074,plan_003,5740000,5740000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0275,acct_0075,parent_0075,plan_003,5750000,5750000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0276,acct_0076,parent_0076,plan_003,5760000,5760000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0277,acct_0077,parent_0077,plan_003,5770000,5770000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0278,acct_0078,parent_0078,plan_003,5780000,5780000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0279,acct_0079,parent_0079,plan_003,5790000,5790000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0280,acct_0080,parent_0080,plan_003,5800000,5800000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0281,acct_0081,parent_0081,plan_002,5810000,5810000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0282,acct_0082,parent_0082,plan_002,5820000,5820000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0283,acct_0083,parent_0083,plan_002,5830000,5830000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0284,acct_0084,parent_0084,plan_002,5840000,5840000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0285,acct_0085,parent_0085,plan_002,5850000,5850000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0286,acct_0086,parent_0086,plan_002,5860000,5860000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0287,acct_0087,parent_0087,plan_004,5870000,5870000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0288,acct_0088,parent_0088,plan_002,5880000,5880000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0289,acct_0089,parent_0089,plan_002,5890000,5890000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0290,acct_0090,parent_0090,plan_002,5900000,5900000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0291,acct_0091,parent_0091,plan_002,5910000,5910000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0292,acct_0092,parent_0092,plan_002,5920000,5920000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0293,acct_0093,parent_0093,plan_002,5930000,5930000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0294,acct_0094,parent_0094,plan_002,5940000,5940000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0295,acct_0095,parent_0095,plan_002,5950000,5950000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0296,acct_0096,parent_0096,plan_002,5960000,5960000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0297,acct_0097,parent_0097,plan_002,5970000,5970000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0298,acct_0098,parent_0098,plan_002,5980000,5980000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0299,acct_0099,parent_0099,plan_002,5990000,5990000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0300,acct_0100,parent_0100,plan_002,6000000,6000000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0301,acct_0101,parent_0101,plan_002,6010000,6010000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0302,acct_0102,parent_0102,plan_002,6020000,6020000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0303,acct_0103,parent_0103,plan_002,6030000,6030000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0304,acct_0104,parent_0104,plan_002,6040000,6040000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0305,acct_0105,parent_0105,plan_002,6050000,6050000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0306,acct_0106,parent_0106,plan_002,6060000,6060000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0307,acct_0107,parent_0107,plan_002,6070000,6070000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0308,acct_0108,parent_0108,plan_002,6080000,6080000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0309,acct_0109,parent_0109,plan_002,6090000,6090000,2025-03-01,2025-12-31,cancelled,new
|
||||
contract_0310,acct_0110,parent_0110,plan_002,6100000,6100000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0311,acct_0111,parent_0111,plan_002,6110000,6110000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0312,acct_0112,parent_0112,plan_002,6120000,6120000,2025-03-01,2025-12-31,cancelled,downgrade
|
||||
contract_0313,acct_0113,parent_0113,plan_002,6130000,6130000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0314,acct_0114,parent_0114,plan_002,6140000,6140000,2025-03-01,2025-12-31,expired,renewal
|
||||
contract_0315,acct_0115,parent_0115,plan_002,6150000,6150000,2025-03-01,2025-12-31,cancelled,expansion
|
||||
contract_0316,acct_0116,parent_0116,plan_002,6160000,6160000,2025-03-01,2025-12-31,expired,downgrade
|
||||
contract_0317,acct_0117,parent_0117,plan_002,6170000,6170000,2025-03-01,2025-12-31,expired,new
|
||||
contract_0318,acct_0118,parent_0118,plan_002,6180000,6180000,2025-03-01,2025-12-31,cancelled,renewal
|
||||
contract_0319,acct_0119,parent_0119,plan_002,6190000,6190000,2025-03-01,2025-12-31,expired,expansion
|
||||
contract_0320,acct_0120,parent_0120,plan_002,6200000,6200000,2025-03-01,2025-12-31,expired,downgrade
|
||||
|
3001
packages/cli/assets/demo/orbit/raw-sources/warehouse/invoices.csv
Normal file
3001
packages/cli/assets/demo/orbit/raw-sources/warehouse/invoices.csv
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,5 @@
|
|||
plan_id,plan_code,plan_name,canonical_plan_code,is_retired,retired_at
|
||||
plan_001,starter,Starter,starter,false,2099-12-31T00:00:00Z
|
||||
plan_002,growth,Growth,growth,false,2099-12-31T00:00:00Z
|
||||
plan_003,enterprise,Enterprise,enterprise,false,2099-12-31T00:00:00Z
|
||||
plan_004,pro_plus,Pro Plus,growth,true,2025-10-01T00:00:00Z
|
||||
|
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,521 @@
|
|||
support_ticket_id,account_id,requester_user_id,severity,category,status,created_at,resolved_at,owner_user_id
|
||||
ticket_0001,acct_0001,user_000287,critical,approval_routing,open,2026-03-10T17:00:00Z,2099-12-31T00:00:00Z,user_000901
|
||||
ticket_0002,acct_0002,user_000288,high,approval_routing,open,2026-03-11T17:00:00Z,2099-12-31T00:00:00Z,user_000902
|
||||
ticket_0003,acct_0003,user_000289,critical,approval_routing,open,2026-03-12T17:00:00Z,2099-12-31T00:00:00Z,user_000903
|
||||
ticket_0004,acct_0004,user_000290,high,approval_routing,open,2026-03-13T17:00:00Z,2099-12-31T00:00:00Z,user_000904
|
||||
ticket_0005,acct_0005,user_000291,critical,approval_routing,open,2026-03-14T17:00:00Z,2099-12-31T00:00:00Z,user_000905
|
||||
ticket_0006,acct_0006,user_000292,high,approval_routing,open,2026-03-15T17:00:00Z,2099-12-31T00:00:00Z,user_000906
|
||||
ticket_0007,acct_0007,user_000293,critical,approval_routing,open,2026-03-16T17:00:00Z,2099-12-31T00:00:00Z,user_000907
|
||||
ticket_0008,acct_0008,user_000294,high,approval_routing,open,2026-03-17T17:00:00Z,2099-12-31T00:00:00Z,user_000908
|
||||
ticket_0009,acct_0009,user_000295,critical,approval_routing,open,2026-03-18T17:00:00Z,2099-12-31T00:00:00Z,user_000909
|
||||
ticket_0010,acct_0010,user_000010,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000909
|
||||
ticket_0011,acct_0011,user_000011,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000910
|
||||
ticket_0012,acct_0012,user_000012,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000911
|
||||
ticket_0013,acct_0013,user_000013,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000912
|
||||
ticket_0014,acct_0014,user_000014,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000913
|
||||
ticket_0015,acct_0015,user_000015,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000914
|
||||
ticket_0016,acct_0016,user_000016,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000915
|
||||
ticket_0017,acct_0017,user_000017,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000916
|
||||
ticket_0018,acct_0018,user_000018,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000917
|
||||
ticket_0019,acct_0019,user_000019,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000918
|
||||
ticket_0020,acct_0020,user_000020,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000919
|
||||
ticket_0021,acct_0021,user_000021,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000920
|
||||
ticket_0022,acct_0022,user_000022,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000921
|
||||
ticket_0023,acct_0023,user_000023,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000922
|
||||
ticket_0024,acct_0024,user_000024,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000923
|
||||
ticket_0025,acct_0025,user_000025,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000924
|
||||
ticket_0026,acct_0026,user_000026,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000925
|
||||
ticket_0027,acct_0027,user_000027,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000926
|
||||
ticket_0028,acct_0028,user_000028,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000927
|
||||
ticket_0029,acct_0029,user_000029,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000928
|
||||
ticket_0030,acct_0030,user_000030,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000929
|
||||
ticket_0031,acct_0031,user_000031,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000930
|
||||
ticket_0032,acct_0032,user_000032,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000931
|
||||
ticket_0033,acct_0033,user_000033,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000932
|
||||
ticket_0034,acct_0034,user_000034,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000933
|
||||
ticket_0035,acct_0035,user_000035,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000934
|
||||
ticket_0036,acct_0036,user_000036,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000935
|
||||
ticket_0037,acct_0037,user_000037,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000936
|
||||
ticket_0038,acct_0038,user_000038,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000937
|
||||
ticket_0039,acct_0039,user_000039,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000938
|
||||
ticket_0040,acct_0040,user_000040,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000939
|
||||
ticket_0041,acct_0041,user_000041,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000940
|
||||
ticket_0042,acct_0042,user_000042,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000941
|
||||
ticket_0043,acct_0043,user_000043,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000942
|
||||
ticket_0044,acct_0044,user_000044,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000943
|
||||
ticket_0045,acct_0045,user_000045,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000944
|
||||
ticket_0046,acct_0046,user_000046,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000945
|
||||
ticket_0047,acct_0047,user_000047,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000946
|
||||
ticket_0048,acct_0048,user_000048,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000947
|
||||
ticket_0049,acct_0049,user_000049,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000948
|
||||
ticket_0050,acct_0050,user_000050,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000949
|
||||
ticket_0051,acct_0051,user_000051,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000950
|
||||
ticket_0052,acct_0052,user_000052,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000951
|
||||
ticket_0053,acct_0053,user_000053,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000952
|
||||
ticket_0054,acct_0054,user_000054,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000953
|
||||
ticket_0055,acct_0055,user_000055,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000954
|
||||
ticket_0056,acct_0056,user_000056,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000955
|
||||
ticket_0057,acct_0057,user_000057,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000956
|
||||
ticket_0058,acct_0058,user_000058,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000957
|
||||
ticket_0059,acct_0059,user_000059,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000958
|
||||
ticket_0060,acct_0060,user_000060,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000959
|
||||
ticket_0061,acct_0061,user_000061,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000960
|
||||
ticket_0062,acct_0062,user_000062,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000961
|
||||
ticket_0063,acct_0063,user_000063,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000962
|
||||
ticket_0064,acct_0064,user_000064,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000963
|
||||
ticket_0065,acct_0065,user_000065,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000964
|
||||
ticket_0066,acct_0066,user_000066,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000965
|
||||
ticket_0067,acct_0067,user_000067,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000966
|
||||
ticket_0068,acct_0068,user_000068,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000967
|
||||
ticket_0069,acct_0069,user_000069,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000968
|
||||
ticket_0070,acct_0070,user_000070,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000969
|
||||
ticket_0071,acct_0071,user_000071,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000970
|
||||
ticket_0072,acct_0072,user_000072,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000971
|
||||
ticket_0073,acct_0073,user_000073,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000972
|
||||
ticket_0074,acct_0074,user_000074,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000973
|
||||
ticket_0075,acct_0075,user_000075,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000974
|
||||
ticket_0076,acct_0076,user_000076,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000975
|
||||
ticket_0077,acct_0077,user_000077,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000976
|
||||
ticket_0078,acct_0078,user_000078,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000977
|
||||
ticket_0079,acct_0079,user_000079,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000978
|
||||
ticket_0080,acct_0080,user_000080,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000979
|
||||
ticket_0081,acct_0081,user_000081,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000980
|
||||
ticket_0082,acct_0082,user_000082,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000981
|
||||
ticket_0083,acct_0083,user_000083,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000982
|
||||
ticket_0084,acct_0084,user_000084,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000983
|
||||
ticket_0085,acct_0085,user_000085,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000984
|
||||
ticket_0086,acct_0086,user_000086,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000985
|
||||
ticket_0087,acct_0087,user_000087,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000986
|
||||
ticket_0088,acct_0088,user_000088,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000987
|
||||
ticket_0089,acct_0089,user_000089,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000988
|
||||
ticket_0090,acct_0090,user_000090,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000989
|
||||
ticket_0091,acct_0091,user_000091,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000990
|
||||
ticket_0092,acct_0092,user_000092,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000991
|
||||
ticket_0093,acct_0093,user_000093,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000992
|
||||
ticket_0094,acct_0094,user_000094,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000993
|
||||
ticket_0095,acct_0095,user_000095,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000994
|
||||
ticket_0096,acct_0096,user_000096,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000995
|
||||
ticket_0097,acct_0097,user_000097,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000996
|
||||
ticket_0098,acct_0098,user_000098,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000997
|
||||
ticket_0099,acct_0099,user_000099,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000998
|
||||
ticket_0100,acct_0100,user_000100,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000999
|
||||
ticket_0101,acct_0101,user_000101,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001000
|
||||
ticket_0102,acct_0102,user_000102,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001001
|
||||
ticket_0103,acct_0103,user_000103,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001002
|
||||
ticket_0104,acct_0104,user_000104,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001003
|
||||
ticket_0105,acct_0105,user_000105,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001004
|
||||
ticket_0106,acct_0106,user_000106,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001005
|
||||
ticket_0107,acct_0107,user_000107,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001006
|
||||
ticket_0108,acct_0108,user_000108,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001007
|
||||
ticket_0109,acct_0109,user_000109,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001008
|
||||
ticket_0110,acct_0110,user_000110,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001009
|
||||
ticket_0111,acct_0111,user_000111,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001010
|
||||
ticket_0112,acct_0112,user_000112,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001011
|
||||
ticket_0113,acct_0113,user_000113,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001012
|
||||
ticket_0114,acct_0114,user_000114,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001013
|
||||
ticket_0115,acct_0115,user_000115,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001014
|
||||
ticket_0116,acct_0116,user_000116,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001015
|
||||
ticket_0117,acct_0117,user_000117,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001016
|
||||
ticket_0118,acct_0118,user_000118,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001017
|
||||
ticket_0119,acct_0119,user_000119,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001018
|
||||
ticket_0120,acct_0120,user_000120,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001019
|
||||
ticket_0121,acct_0121,user_000121,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001020
|
||||
ticket_0122,acct_0122,user_000122,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001021
|
||||
ticket_0123,acct_0123,user_000123,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001022
|
||||
ticket_0124,acct_0124,user_000124,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001023
|
||||
ticket_0125,acct_0125,user_000125,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001024
|
||||
ticket_0126,acct_0126,user_000126,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001025
|
||||
ticket_0127,acct_0127,user_000127,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001026
|
||||
ticket_0128,acct_0128,user_000128,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001027
|
||||
ticket_0129,acct_0129,user_000129,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001028
|
||||
ticket_0130,acct_0130,user_000130,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001029
|
||||
ticket_0131,acct_0131,user_000131,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001030
|
||||
ticket_0132,acct_0132,user_000132,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001031
|
||||
ticket_0133,acct_0133,user_000133,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001032
|
||||
ticket_0134,acct_0134,user_000134,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001033
|
||||
ticket_0135,acct_0135,user_000135,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001034
|
||||
ticket_0136,acct_0136,user_000136,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001035
|
||||
ticket_0137,acct_0137,user_000137,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001036
|
||||
ticket_0138,acct_0138,user_000138,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001037
|
||||
ticket_0139,acct_0139,user_000139,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001038
|
||||
ticket_0140,acct_0140,user_000140,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001039
|
||||
ticket_0141,acct_0141,user_000141,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001040
|
||||
ticket_0142,acct_0142,user_000142,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001041
|
||||
ticket_0143,acct_0143,user_000143,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001042
|
||||
ticket_0144,acct_0144,user_000144,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001043
|
||||
ticket_0145,acct_0145,user_000145,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001044
|
||||
ticket_0146,acct_0146,user_000146,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001045
|
||||
ticket_0147,acct_0147,user_000147,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001046
|
||||
ticket_0148,acct_0148,user_000148,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001047
|
||||
ticket_0149,acct_0149,user_000149,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001048
|
||||
ticket_0150,acct_0150,user_000150,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001049
|
||||
ticket_0151,acct_0151,user_000151,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001050
|
||||
ticket_0152,acct_0152,user_000152,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001051
|
||||
ticket_0153,acct_0153,user_000153,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001052
|
||||
ticket_0154,acct_0154,user_000154,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001053
|
||||
ticket_0155,acct_0155,user_000155,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001054
|
||||
ticket_0156,acct_0156,user_000156,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001055
|
||||
ticket_0157,acct_0157,user_000157,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001056
|
||||
ticket_0158,acct_0158,user_000158,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001057
|
||||
ticket_0159,acct_0159,user_000159,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001058
|
||||
ticket_0160,acct_0160,user_000160,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001059
|
||||
ticket_0161,acct_0161,user_000161,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001060
|
||||
ticket_0162,acct_0162,user_000162,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001061
|
||||
ticket_0163,acct_0163,user_000163,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001062
|
||||
ticket_0164,acct_0164,user_000164,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001063
|
||||
ticket_0165,acct_0165,user_000165,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001064
|
||||
ticket_0166,acct_0166,user_000166,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001065
|
||||
ticket_0167,acct_0167,user_000167,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001066
|
||||
ticket_0168,acct_0168,user_000168,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001067
|
||||
ticket_0169,acct_0169,user_000169,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001068
|
||||
ticket_0170,acct_0170,user_000170,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001069
|
||||
ticket_0171,acct_0171,user_000171,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001070
|
||||
ticket_0172,acct_0172,user_000172,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001071
|
||||
ticket_0173,acct_0173,user_000173,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001072
|
||||
ticket_0174,acct_0174,user_000174,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001073
|
||||
ticket_0175,acct_0175,user_000175,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001074
|
||||
ticket_0176,acct_0176,user_000176,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001075
|
||||
ticket_0177,acct_0177,user_000177,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001076
|
||||
ticket_0178,acct_0178,user_000178,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001077
|
||||
ticket_0179,acct_0179,user_000179,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001078
|
||||
ticket_0180,acct_0180,user_000180,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001079
|
||||
ticket_0181,acct_0181,user_000181,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001080
|
||||
ticket_0182,acct_0182,user_000182,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001081
|
||||
ticket_0183,acct_0183,user_000183,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001082
|
||||
ticket_0184,acct_0184,user_000184,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001083
|
||||
ticket_0185,acct_0185,user_000185,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001084
|
||||
ticket_0186,acct_0186,user_000186,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001085
|
||||
ticket_0187,acct_0187,user_000187,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001086
|
||||
ticket_0188,acct_0188,user_000188,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001087
|
||||
ticket_0189,acct_0189,user_000189,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001088
|
||||
ticket_0190,acct_0190,user_000190,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001089
|
||||
ticket_0191,acct_0191,user_000191,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001090
|
||||
ticket_0192,acct_0192,user_000192,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001091
|
||||
ticket_0193,acct_0193,user_000193,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001092
|
||||
ticket_0194,acct_0194,user_000194,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001093
|
||||
ticket_0195,acct_0195,user_000195,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001094
|
||||
ticket_0196,acct_0196,user_000196,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001095
|
||||
ticket_0197,acct_0197,user_000197,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001096
|
||||
ticket_0198,acct_0198,user_000198,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001097
|
||||
ticket_0199,acct_0199,user_000199,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001098
|
||||
ticket_0200,acct_0010,user_000200,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001099
|
||||
ticket_0201,acct_0011,user_000201,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000900
|
||||
ticket_0202,acct_0012,user_000202,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000901
|
||||
ticket_0203,acct_0013,user_000203,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000902
|
||||
ticket_0204,acct_0014,user_000204,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000903
|
||||
ticket_0205,acct_0015,user_000205,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000904
|
||||
ticket_0206,acct_0016,user_000206,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000905
|
||||
ticket_0207,acct_0017,user_000207,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000906
|
||||
ticket_0208,acct_0018,user_000208,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000907
|
||||
ticket_0209,acct_0019,user_000209,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000908
|
||||
ticket_0210,acct_0020,user_000210,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000909
|
||||
ticket_0211,acct_0021,user_000211,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000910
|
||||
ticket_0212,acct_0022,user_000212,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000911
|
||||
ticket_0213,acct_0023,user_000213,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000912
|
||||
ticket_0214,acct_0024,user_000214,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000913
|
||||
ticket_0215,acct_0025,user_000215,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000914
|
||||
ticket_0216,acct_0026,user_000216,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000915
|
||||
ticket_0217,acct_0027,user_000217,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000916
|
||||
ticket_0218,acct_0028,user_000218,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000917
|
||||
ticket_0219,acct_0029,user_000219,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000918
|
||||
ticket_0220,acct_0030,user_000220,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000919
|
||||
ticket_0221,acct_0031,user_000221,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000920
|
||||
ticket_0222,acct_0032,user_000222,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000921
|
||||
ticket_0223,acct_0033,user_000223,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000922
|
||||
ticket_0224,acct_0034,user_000224,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000923
|
||||
ticket_0225,acct_0035,user_000225,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000924
|
||||
ticket_0226,acct_0036,user_000226,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000925
|
||||
ticket_0227,acct_0037,user_000227,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000926
|
||||
ticket_0228,acct_0038,user_000228,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000927
|
||||
ticket_0229,acct_0039,user_000229,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000928
|
||||
ticket_0230,acct_0040,user_000230,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000929
|
||||
ticket_0231,acct_0041,user_000231,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000930
|
||||
ticket_0232,acct_0042,user_000232,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000931
|
||||
ticket_0233,acct_0043,user_000233,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000932
|
||||
ticket_0234,acct_0044,user_000234,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000933
|
||||
ticket_0235,acct_0045,user_000235,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000934
|
||||
ticket_0236,acct_0046,user_000236,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000935
|
||||
ticket_0237,acct_0047,user_000237,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000936
|
||||
ticket_0238,acct_0048,user_000238,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000937
|
||||
ticket_0239,acct_0049,user_000239,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000938
|
||||
ticket_0240,acct_0050,user_000240,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000939
|
||||
ticket_0241,acct_0051,user_000241,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000940
|
||||
ticket_0242,acct_0052,user_000242,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000941
|
||||
ticket_0243,acct_0053,user_000243,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000942
|
||||
ticket_0244,acct_0054,user_000244,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000943
|
||||
ticket_0245,acct_0055,user_000245,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000944
|
||||
ticket_0246,acct_0056,user_000246,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000945
|
||||
ticket_0247,acct_0057,user_000247,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000946
|
||||
ticket_0248,acct_0058,user_000248,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000947
|
||||
ticket_0249,acct_0059,user_000249,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000948
|
||||
ticket_0250,acct_0060,user_000250,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000949
|
||||
ticket_0251,acct_0061,user_000251,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000950
|
||||
ticket_0252,acct_0062,user_000252,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000951
|
||||
ticket_0253,acct_0063,user_000253,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000952
|
||||
ticket_0254,acct_0064,user_000254,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000953
|
||||
ticket_0255,acct_0065,user_000255,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000954
|
||||
ticket_0256,acct_0066,user_000256,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000955
|
||||
ticket_0257,acct_0067,user_000257,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000956
|
||||
ticket_0258,acct_0068,user_000258,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000957
|
||||
ticket_0259,acct_0069,user_000259,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000958
|
||||
ticket_0260,acct_0070,user_000260,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000959
|
||||
ticket_0261,acct_0071,user_000261,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000960
|
||||
ticket_0262,acct_0072,user_000262,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000961
|
||||
ticket_0263,acct_0073,user_000263,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000962
|
||||
ticket_0264,acct_0074,user_000264,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000963
|
||||
ticket_0265,acct_0075,user_000265,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000964
|
||||
ticket_0266,acct_0076,user_000266,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000965
|
||||
ticket_0267,acct_0077,user_000267,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000966
|
||||
ticket_0268,acct_0078,user_000268,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000967
|
||||
ticket_0269,acct_0079,user_000269,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000968
|
||||
ticket_0270,acct_0080,user_000270,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000969
|
||||
ticket_0271,acct_0081,user_000271,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000970
|
||||
ticket_0272,acct_0082,user_000272,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000971
|
||||
ticket_0273,acct_0083,user_000273,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000972
|
||||
ticket_0274,acct_0084,user_000274,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000973
|
||||
ticket_0275,acct_0085,user_000275,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000974
|
||||
ticket_0276,acct_0086,user_000276,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000975
|
||||
ticket_0277,acct_0087,user_000277,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000976
|
||||
ticket_0278,acct_0088,user_000278,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000977
|
||||
ticket_0279,acct_0089,user_000279,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000978
|
||||
ticket_0280,acct_0090,user_000280,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000979
|
||||
ticket_0281,acct_0091,user_000281,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000980
|
||||
ticket_0282,acct_0092,user_000282,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000981
|
||||
ticket_0283,acct_0093,user_000283,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000982
|
||||
ticket_0284,acct_0094,user_000284,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000983
|
||||
ticket_0285,acct_0095,user_000285,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000984
|
||||
ticket_0286,acct_0096,user_000286,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000985
|
||||
ticket_0287,acct_0097,user_000287,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000986
|
||||
ticket_0288,acct_0098,user_000288,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000987
|
||||
ticket_0289,acct_0099,user_000289,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000988
|
||||
ticket_0290,acct_0100,user_000290,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000989
|
||||
ticket_0291,acct_0101,user_000291,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000990
|
||||
ticket_0292,acct_0102,user_000292,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000991
|
||||
ticket_0293,acct_0103,user_000293,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000992
|
||||
ticket_0294,acct_0104,user_000294,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000993
|
||||
ticket_0295,acct_0105,user_000295,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000994
|
||||
ticket_0296,acct_0106,user_000296,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000995
|
||||
ticket_0297,acct_0107,user_000297,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000996
|
||||
ticket_0298,acct_0108,user_000298,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000997
|
||||
ticket_0299,acct_0109,user_000299,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000998
|
||||
ticket_0300,acct_0110,user_000300,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000999
|
||||
ticket_0301,acct_0111,user_000301,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001000
|
||||
ticket_0302,acct_0112,user_000302,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001001
|
||||
ticket_0303,acct_0113,user_000303,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001002
|
||||
ticket_0304,acct_0114,user_000304,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001003
|
||||
ticket_0305,acct_0115,user_000305,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001004
|
||||
ticket_0306,acct_0116,user_000306,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001005
|
||||
ticket_0307,acct_0117,user_000307,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001006
|
||||
ticket_0308,acct_0118,user_000308,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001007
|
||||
ticket_0309,acct_0119,user_000309,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001008
|
||||
ticket_0310,acct_0120,user_000310,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001009
|
||||
ticket_0311,acct_0121,user_000311,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001010
|
||||
ticket_0312,acct_0122,user_000312,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001011
|
||||
ticket_0313,acct_0123,user_000313,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001012
|
||||
ticket_0314,acct_0124,user_000314,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001013
|
||||
ticket_0315,acct_0125,user_000315,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001014
|
||||
ticket_0316,acct_0126,user_000316,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001015
|
||||
ticket_0317,acct_0127,user_000317,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001016
|
||||
ticket_0318,acct_0128,user_000318,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001017
|
||||
ticket_0319,acct_0129,user_000319,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001018
|
||||
ticket_0320,acct_0130,user_000320,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001019
|
||||
ticket_0321,acct_0131,user_000321,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001020
|
||||
ticket_0322,acct_0132,user_000322,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001021
|
||||
ticket_0323,acct_0133,user_000323,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001022
|
||||
ticket_0324,acct_0134,user_000324,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001023
|
||||
ticket_0325,acct_0135,user_000325,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001024
|
||||
ticket_0326,acct_0136,user_000326,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001025
|
||||
ticket_0327,acct_0137,user_000327,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001026
|
||||
ticket_0328,acct_0138,user_000328,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001027
|
||||
ticket_0329,acct_0139,user_000329,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001028
|
||||
ticket_0330,acct_0140,user_000330,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001029
|
||||
ticket_0331,acct_0141,user_000331,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001030
|
||||
ticket_0332,acct_0142,user_000332,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001031
|
||||
ticket_0333,acct_0143,user_000333,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001032
|
||||
ticket_0334,acct_0144,user_000334,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001033
|
||||
ticket_0335,acct_0145,user_000335,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001034
|
||||
ticket_0336,acct_0146,user_000336,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001035
|
||||
ticket_0337,acct_0147,user_000337,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001036
|
||||
ticket_0338,acct_0148,user_000338,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001037
|
||||
ticket_0339,acct_0149,user_000339,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001038
|
||||
ticket_0340,acct_0150,user_000340,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001039
|
||||
ticket_0341,acct_0151,user_000341,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001040
|
||||
ticket_0342,acct_0152,user_000342,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001041
|
||||
ticket_0343,acct_0153,user_000343,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001042
|
||||
ticket_0344,acct_0154,user_000344,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001043
|
||||
ticket_0345,acct_0155,user_000345,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001044
|
||||
ticket_0346,acct_0156,user_000346,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001045
|
||||
ticket_0347,acct_0157,user_000347,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001046
|
||||
ticket_0348,acct_0158,user_000348,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001047
|
||||
ticket_0349,acct_0159,user_000349,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001048
|
||||
ticket_0350,acct_0160,user_000350,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001049
|
||||
ticket_0351,acct_0161,user_000351,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001050
|
||||
ticket_0352,acct_0162,user_000352,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001051
|
||||
ticket_0353,acct_0163,user_000353,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001052
|
||||
ticket_0354,acct_0164,user_000354,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001053
|
||||
ticket_0355,acct_0165,user_000355,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001054
|
||||
ticket_0356,acct_0166,user_000356,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001055
|
||||
ticket_0357,acct_0167,user_000357,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001056
|
||||
ticket_0358,acct_0168,user_000358,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001057
|
||||
ticket_0359,acct_0169,user_000359,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001058
|
||||
ticket_0360,acct_0170,user_000360,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001059
|
||||
ticket_0361,acct_0171,user_000361,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001060
|
||||
ticket_0362,acct_0172,user_000362,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001061
|
||||
ticket_0363,acct_0173,user_000363,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001062
|
||||
ticket_0364,acct_0174,user_000364,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001063
|
||||
ticket_0365,acct_0175,user_000365,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001064
|
||||
ticket_0366,acct_0176,user_000366,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001065
|
||||
ticket_0367,acct_0177,user_000367,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001066
|
||||
ticket_0368,acct_0178,user_000368,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001067
|
||||
ticket_0369,acct_0179,user_000369,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001068
|
||||
ticket_0370,acct_0180,user_000370,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001069
|
||||
ticket_0371,acct_0181,user_000371,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001070
|
||||
ticket_0372,acct_0182,user_000372,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001071
|
||||
ticket_0373,acct_0183,user_000373,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001072
|
||||
ticket_0374,acct_0184,user_000374,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001073
|
||||
ticket_0375,acct_0185,user_000375,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001074
|
||||
ticket_0376,acct_0186,user_000376,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001075
|
||||
ticket_0377,acct_0187,user_000377,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001076
|
||||
ticket_0378,acct_0188,user_000378,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001077
|
||||
ticket_0379,acct_0189,user_000379,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001078
|
||||
ticket_0380,acct_0190,user_000380,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001079
|
||||
ticket_0381,acct_0191,user_000381,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_001080
|
||||
ticket_0382,acct_0192,user_000382,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_001081
|
||||
ticket_0383,acct_0193,user_000383,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_001082
|
||||
ticket_0384,acct_0194,user_000384,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_001083
|
||||
ticket_0385,acct_0195,user_000385,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_001084
|
||||
ticket_0386,acct_0196,user_000386,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_001085
|
||||
ticket_0387,acct_0197,user_000387,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_001086
|
||||
ticket_0388,acct_0198,user_000388,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_001087
|
||||
ticket_0389,acct_0199,user_000389,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001088
|
||||
ticket_0390,acct_0010,user_000390,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001089
|
||||
ticket_0391,acct_0011,user_000391,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001090
|
||||
ticket_0392,acct_0012,user_000392,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001091
|
||||
ticket_0393,acct_0013,user_000393,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001092
|
||||
ticket_0394,acct_0014,user_000394,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001093
|
||||
ticket_0395,acct_0015,user_000395,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001094
|
||||
ticket_0396,acct_0016,user_000396,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001095
|
||||
ticket_0397,acct_0017,user_000397,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001096
|
||||
ticket_0398,acct_0018,user_000398,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001097
|
||||
ticket_0399,acct_0019,user_000399,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001098
|
||||
ticket_0400,acct_0020,user_000400,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001099
|
||||
ticket_0401,acct_0021,user_000401,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000900
|
||||
ticket_0402,acct_0022,user_000402,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000901
|
||||
ticket_0403,acct_0023,user_000403,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000902
|
||||
ticket_0404,acct_0024,user_000404,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000903
|
||||
ticket_0405,acct_0025,user_000405,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000904
|
||||
ticket_0406,acct_0026,user_000406,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000905
|
||||
ticket_0407,acct_0027,user_000407,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000906
|
||||
ticket_0408,acct_0028,user_000408,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000907
|
||||
ticket_0409,acct_0029,user_000409,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000908
|
||||
ticket_0410,acct_0030,user_000410,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000909
|
||||
ticket_0411,acct_0031,user_000411,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000910
|
||||
ticket_0412,acct_0032,user_000412,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000911
|
||||
ticket_0413,acct_0033,user_000413,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000912
|
||||
ticket_0414,acct_0034,user_000414,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000913
|
||||
ticket_0415,acct_0035,user_000415,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000914
|
||||
ticket_0416,acct_0036,user_000416,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000915
|
||||
ticket_0417,acct_0037,user_000417,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000916
|
||||
ticket_0418,acct_0038,user_000418,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000917
|
||||
ticket_0419,acct_0039,user_000419,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000918
|
||||
ticket_0420,acct_0040,user_000420,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000919
|
||||
ticket_0421,acct_0041,user_000421,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000920
|
||||
ticket_0422,acct_0042,user_000422,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000921
|
||||
ticket_0423,acct_0043,user_000423,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000922
|
||||
ticket_0424,acct_0044,user_000424,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000923
|
||||
ticket_0425,acct_0045,user_000425,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000924
|
||||
ticket_0426,acct_0046,user_000426,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000925
|
||||
ticket_0427,acct_0047,user_000427,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000926
|
||||
ticket_0428,acct_0048,user_000428,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000927
|
||||
ticket_0429,acct_0049,user_000429,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000928
|
||||
ticket_0430,acct_0050,user_000430,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000929
|
||||
ticket_0431,acct_0051,user_000431,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000930
|
||||
ticket_0432,acct_0052,user_000432,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000931
|
||||
ticket_0433,acct_0053,user_000433,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000932
|
||||
ticket_0434,acct_0054,user_000434,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000933
|
||||
ticket_0435,acct_0055,user_000435,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000934
|
||||
ticket_0436,acct_0056,user_000436,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000935
|
||||
ticket_0437,acct_0057,user_000437,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000936
|
||||
ticket_0438,acct_0058,user_000438,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000937
|
||||
ticket_0439,acct_0059,user_000439,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000938
|
||||
ticket_0440,acct_0060,user_000440,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000939
|
||||
ticket_0441,acct_0061,user_000441,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000940
|
||||
ticket_0442,acct_0062,user_000442,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000941
|
||||
ticket_0443,acct_0063,user_000443,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000942
|
||||
ticket_0444,acct_0064,user_000444,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000943
|
||||
ticket_0445,acct_0065,user_000445,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000944
|
||||
ticket_0446,acct_0066,user_000446,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000945
|
||||
ticket_0447,acct_0067,user_000447,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000946
|
||||
ticket_0448,acct_0068,user_000448,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000947
|
||||
ticket_0449,acct_0069,user_000449,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000948
|
||||
ticket_0450,acct_0070,user_000450,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000949
|
||||
ticket_0451,acct_0071,user_000451,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000950
|
||||
ticket_0452,acct_0072,user_000452,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000951
|
||||
ticket_0453,acct_0073,user_000453,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000952
|
||||
ticket_0454,acct_0074,user_000454,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000953
|
||||
ticket_0455,acct_0075,user_000455,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000954
|
||||
ticket_0456,acct_0076,user_000456,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000955
|
||||
ticket_0457,acct_0077,user_000457,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000956
|
||||
ticket_0458,acct_0078,user_000458,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000957
|
||||
ticket_0459,acct_0079,user_000459,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000958
|
||||
ticket_0460,acct_0080,user_000460,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000959
|
||||
ticket_0461,acct_0081,user_000461,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000960
|
||||
ticket_0462,acct_0082,user_000462,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000961
|
||||
ticket_0463,acct_0083,user_000463,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000962
|
||||
ticket_0464,acct_0084,user_000464,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000963
|
||||
ticket_0465,acct_0085,user_000465,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000964
|
||||
ticket_0466,acct_0086,user_000466,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000965
|
||||
ticket_0467,acct_0087,user_000467,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000966
|
||||
ticket_0468,acct_0088,user_000468,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000967
|
||||
ticket_0469,acct_0089,user_000469,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000968
|
||||
ticket_0470,acct_0090,user_000470,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000969
|
||||
ticket_0471,acct_0091,user_000471,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000970
|
||||
ticket_0472,acct_0092,user_000472,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000971
|
||||
ticket_0473,acct_0093,user_000473,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_000972
|
||||
ticket_0474,acct_0094,user_000474,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_000973
|
||||
ticket_0475,acct_0095,user_000475,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_000974
|
||||
ticket_0476,acct_0096,user_000476,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_000975
|
||||
ticket_0477,acct_0097,user_000477,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_000976
|
||||
ticket_0478,acct_0098,user_000478,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_000977
|
||||
ticket_0479,acct_0099,user_000479,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_000978
|
||||
ticket_0480,acct_0100,user_000480,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_000979
|
||||
ticket_0481,acct_0101,user_000481,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_000980
|
||||
ticket_0482,acct_0102,user_000482,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_000981
|
||||
ticket_0483,acct_0103,user_000483,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_000982
|
||||
ticket_0484,acct_0104,user_000484,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_000983
|
||||
ticket_0485,acct_0105,user_000485,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_000984
|
||||
ticket_0486,acct_0106,user_000486,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_000985
|
||||
ticket_0487,acct_0107,user_000487,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_000986
|
||||
ticket_0488,acct_0108,user_000488,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_000987
|
||||
ticket_0489,acct_0109,user_000489,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_000988
|
||||
ticket_0490,acct_0110,user_000490,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_000989
|
||||
ticket_0491,acct_0111,user_000491,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_000990
|
||||
ticket_0492,acct_0112,user_000492,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_000991
|
||||
ticket_0493,acct_0113,user_000493,low,approval_routing,open,2026-02-17T17:00:00Z,2026-02-28T17:00:00Z,user_000992
|
||||
ticket_0494,acct_0114,user_000494,medium,supplier_onboarding,pending,2026-02-18T17:00:00Z,2026-02-28T17:00:00Z,user_000993
|
||||
ticket_0495,acct_0115,user_000495,high,billing,solved,2026-02-19T17:00:00Z,2026-02-28T17:00:00Z,user_000994
|
||||
ticket_0496,acct_0116,user_000496,critical,permissions,closed,2026-02-20T17:00:00Z,2026-02-28T17:00:00Z,user_000995
|
||||
ticket_0497,acct_0117,user_000497,low,approval_routing,open,2026-02-21T17:00:00Z,2026-02-28T17:00:00Z,user_000996
|
||||
ticket_0498,acct_0118,user_000498,medium,supplier_onboarding,pending,2026-02-22T17:00:00Z,2026-02-28T17:00:00Z,user_000997
|
||||
ticket_0499,acct_0119,user_000499,high,billing,solved,2026-02-23T17:00:00Z,2026-02-28T17:00:00Z,user_000998
|
||||
ticket_0500,acct_0120,user_000500,critical,permissions,closed,2026-02-24T17:00:00Z,2026-02-28T17:00:00Z,user_000999
|
||||
ticket_0501,acct_0121,user_000501,low,approval_routing,open,2026-02-25T17:00:00Z,2026-02-28T17:00:00Z,user_001000
|
||||
ticket_0502,acct_0122,user_000502,medium,supplier_onboarding,pending,2026-02-26T17:00:00Z,2026-02-28T17:00:00Z,user_001001
|
||||
ticket_0503,acct_0123,user_000503,high,billing,solved,2026-02-27T17:00:00Z,2026-02-28T17:00:00Z,user_001002
|
||||
ticket_0504,acct_0124,user_000504,critical,permissions,closed,2026-02-28T17:00:00Z,2026-02-28T17:00:00Z,user_001003
|
||||
ticket_0505,acct_0125,user_000505,low,approval_routing,open,2026-02-01T17:00:00Z,2026-02-28T17:00:00Z,user_001004
|
||||
ticket_0506,acct_0126,user_000506,medium,supplier_onboarding,pending,2026-02-02T17:00:00Z,2026-02-28T17:00:00Z,user_001005
|
||||
ticket_0507,acct_0127,user_000507,high,billing,solved,2026-02-03T17:00:00Z,2026-02-28T17:00:00Z,user_001006
|
||||
ticket_0508,acct_0128,user_000508,critical,permissions,closed,2026-02-04T17:00:00Z,2026-02-28T17:00:00Z,user_001007
|
||||
ticket_0509,acct_0129,user_000509,low,approval_routing,open,2026-02-05T17:00:00Z,2026-02-28T17:00:00Z,user_001008
|
||||
ticket_0510,acct_0130,user_000510,medium,supplier_onboarding,pending,2026-02-06T17:00:00Z,2026-02-28T17:00:00Z,user_001009
|
||||
ticket_0511,acct_0131,user_000511,high,billing,solved,2026-02-07T17:00:00Z,2026-02-28T17:00:00Z,user_001010
|
||||
ticket_0512,acct_0132,user_000512,critical,permissions,closed,2026-02-08T17:00:00Z,2026-02-28T17:00:00Z,user_001011
|
||||
ticket_0513,acct_0133,user_000513,low,approval_routing,open,2026-02-09T17:00:00Z,2026-02-28T17:00:00Z,user_001012
|
||||
ticket_0514,acct_0134,user_000514,medium,supplier_onboarding,pending,2026-02-10T17:00:00Z,2026-02-28T17:00:00Z,user_001013
|
||||
ticket_0515,acct_0135,user_000515,high,billing,solved,2026-02-11T17:00:00Z,2026-02-28T17:00:00Z,user_001014
|
||||
ticket_0516,acct_0136,user_000516,critical,permissions,closed,2026-02-12T17:00:00Z,2026-02-28T17:00:00Z,user_001015
|
||||
ticket_0517,acct_0137,user_000517,low,approval_routing,open,2026-02-13T17:00:00Z,2026-02-28T17:00:00Z,user_001016
|
||||
ticket_0518,acct_0138,user_000518,medium,supplier_onboarding,pending,2026-02-14T17:00:00Z,2026-02-28T17:00:00Z,user_001017
|
||||
ticket_0519,acct_0139,user_000519,high,billing,solved,2026-02-15T17:00:00Z,2026-02-28T17:00:00Z,user_001018
|
||||
ticket_0520,acct_0140,user_000520,critical,permissions,closed,2026-02-16T17:00:00Z,2026-02-28T17:00:00Z,user_001019
|
||||
|
1261
packages/cli/assets/demo/orbit/raw-sources/warehouse/users.csv
Normal file
1261
packages/cli/assets/demo/orbit/raw-sources/warehouse/users.csv
Normal file
File diff suppressed because it is too large
Load diff
707
packages/cli/assets/demo/orbit/replay.memory-flow.v1.json
Normal file
707
packages/cli/assets/demo/orbit/replay.memory-flow.v1.json
Normal file
|
|
@ -0,0 +1,707 @@
|
|||
{
|
||||
"memoryFlowReplaySchemaVersion": 1,
|
||||
"replay": {
|
||||
"runId": "demo-seeded-orbit",
|
||||
"connectionId": "orbit_demo",
|
||||
"adapter": "live-database",
|
||||
"status": "done",
|
||||
"sourceDir": null,
|
||||
"syncId": "demo-seeded-sync",
|
||||
"reportId": "demo-seeded-report",
|
||||
"reportPath": "reports/seeded-demo-report.json",
|
||||
"errors": [],
|
||||
"metadata": {
|
||||
"schemaVersion": 1,
|
||||
"mode": "seeded",
|
||||
"origin": "packaged",
|
||||
"timing": "prebuilt",
|
||||
"capturedAt": "2026-05-06T00:00:00.000Z",
|
||||
"sourceReportId": "demo-seeded-report",
|
||||
"sourceReportPath": "reports/seeded-demo-report.json",
|
||||
"fallbackReason": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "live-database",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 8
|
||||
},
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "dbt_descriptions",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 6
|
||||
},
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "looker",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 7
|
||||
},
|
||||
{
|
||||
"type": "source_acquired",
|
||||
"adapter": "notion",
|
||||
"trigger": "demo_seeded",
|
||||
"fileCount": 8
|
||||
},
|
||||
{
|
||||
"type": "scope_detected",
|
||||
"fingerprint": "sqlite:orbit-demo"
|
||||
},
|
||||
{
|
||||
"type": "raw_snapshot_written",
|
||||
"syncId": "demo-seeded-sync",
|
||||
"rawFileCount": 29
|
||||
},
|
||||
{
|
||||
"type": "diff_computed",
|
||||
"added": 29,
|
||||
"modified": 0,
|
||||
"deleted": 0,
|
||||
"unchanged": 0
|
||||
},
|
||||
{
|
||||
"type": "chunks_planned",
|
||||
"chunkCount": 5,
|
||||
"workUnitCount": 5,
|
||||
"evictionCount": 0
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/arr-contract-first.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/revenue-gross-to-net.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/discount-expiration.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.contracts"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.invoices"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.arr_movements"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "retention-and-segments",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/nrr-retention.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/segment-classification.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.accounts"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "retention-and-segments",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/activation-policy.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/procurement-workflows.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.purchase_requests"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "procurement-and-activation",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "support-and-health",
|
||||
"skills": [
|
||||
"knowledge_capture",
|
||||
"sl_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/customer-health-scoring.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/support-escalation.md"
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "support-and-health",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.support_tickets"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "support-and-health",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_started",
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"skills": [
|
||||
"knowledge_capture"
|
||||
],
|
||||
"stepBudget": 40
|
||||
},
|
||||
{
|
||||
"type": "candidate_action",
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/internal-test-exclusion.md"
|
||||
},
|
||||
{
|
||||
"type": "work_unit_finished",
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"type": "reconciliation_finished",
|
||||
"conflictCount": 0,
|
||||
"fallbackCount": 0
|
||||
},
|
||||
{
|
||||
"type": "saved",
|
||||
"commitSha": "demo-seeded",
|
||||
"wikiCount": 10,
|
||||
"slCount": 6
|
||||
},
|
||||
{
|
||||
"type": "provenance_recorded",
|
||||
"rowCount": 23
|
||||
},
|
||||
{
|
||||
"type": "report_created",
|
||||
"runId": "demo-seeded-orbit",
|
||||
"reportPath": "reports/seeded-demo-report.json"
|
||||
}
|
||||
],
|
||||
"plannedWorkUnits": [
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"invoices",
|
||||
"arr_movements"
|
||||
],
|
||||
"peerFileCount": 3,
|
||||
"dependencyCount": 3
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"plans"
|
||||
],
|
||||
"peerFileCount": 2,
|
||||
"dependencyCount": 2
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"rawFiles": [
|
||||
"purchase_requests",
|
||||
"users"
|
||||
],
|
||||
"peerFileCount": 2,
|
||||
"dependencyCount": 2
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"rawFiles": [
|
||||
"support_tickets"
|
||||
],
|
||||
"peerFileCount": 1,
|
||||
"dependencyCount": 1
|
||||
},
|
||||
{
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"rawFiles": [
|
||||
"notion/export/pages/analyst-onboarding.md"
|
||||
],
|
||||
"peerFileCount": 1,
|
||||
"dependencyCount": 0
|
||||
}
|
||||
],
|
||||
"details": {
|
||||
"actions": [
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/arr-contract-first.md",
|
||||
"summary": "ARR follows contract precedence with cancellation and discount caveats.",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"arr_movements",
|
||||
"raw-sources/notion/arr-and-contract-reporting-notes.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/revenue-gross-to-net.md",
|
||||
"summary": "Invoice, refund, and revenue dashboard evidence reconcile gross to net revenue.",
|
||||
"rawFiles": [
|
||||
"invoices",
|
||||
"raw-sources/bi/revenue_exec.dashboard.lookml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/discount-expiration.md",
|
||||
"summary": "Discount expiration is separated from organic contraction for retention reporting.",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"arr_movements"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.contracts",
|
||||
"summary": "Contract grain with active ARR measures and account joins.",
|
||||
"rawFiles": [
|
||||
"contracts",
|
||||
"raw-sources/dbt/schema.yml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.invoices",
|
||||
"summary": "Invoice status measures tied to gross and net revenue reporting.",
|
||||
"rawFiles": [
|
||||
"invoices",
|
||||
"raw-sources/bi/revenue_daily.view.lkml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.arr_movements",
|
||||
"summary": "ARR movement ledger for expansion, contraction, churn, and NRR.",
|
||||
"rawFiles": [
|
||||
"arr_movements",
|
||||
"raw-sources/bi/account_retention.view.lkml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/nrr-retention.md",
|
||||
"summary": "NRR uses parent-account rollups and quarterly ARR movement windows.",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"arr_movements",
|
||||
"raw-sources/notion/retention-and-nrr-definition-notes.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/segment-classification.md",
|
||||
"summary": "Segment labels come from plan mapping and sales-ops policy notes.",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"plans",
|
||||
"raw-sources/notion/sales-ops-segmentation-guide.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.accounts",
|
||||
"summary": "Account dimensions with lifecycle, segment, and internal-test exclusions.",
|
||||
"rawFiles": [
|
||||
"accounts",
|
||||
"plans"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/activation-policy.md",
|
||||
"summary": "Activation policy changed on January 15, 2026 and is encoded for agents.",
|
||||
"rawFiles": [
|
||||
"purchase_requests",
|
||||
"users",
|
||||
"raw-sources/notion/activation-policy-decision-record.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/procurement-workflows.md",
|
||||
"summary": "Procurement requester activity and approval events explain product usage.",
|
||||
"rawFiles": [
|
||||
"purchase_requests",
|
||||
"raw-sources/bi/procurement_activity.view.lkml"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.purchase_requests",
|
||||
"summary": "Procurement request facts with requester and approval-state measures.",
|
||||
"rawFiles": [
|
||||
"purchase_requests"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/customer-health-scoring.md",
|
||||
"summary": "Customer health combines support severity, ARR exposure, and product usage.",
|
||||
"rawFiles": [
|
||||
"support_tickets",
|
||||
"raw-sources/notion/customer-health-playbook.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/support-escalation.md",
|
||||
"summary": "Escalation tiers map ticket severity to SLA expectations.",
|
||||
"rawFiles": [
|
||||
"support_tickets",
|
||||
"raw-sources/notion/support-escalation-runbook.md"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"target": "sl",
|
||||
"action": "created",
|
||||
"key": "orbit_demo.support_tickets",
|
||||
"summary": "Support ticket facts with severity, status, and resolution-hour measures.",
|
||||
"rawFiles": [
|
||||
"support_tickets"
|
||||
],
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"target": "wiki",
|
||||
"action": "created",
|
||||
"key": "knowledge/global/internal-test-exclusion.md",
|
||||
"summary": "Canonical metrics exclude internal and test accounts across source families.",
|
||||
"rawFiles": [
|
||||
"raw-sources/notion/analyst-onboarding.md"
|
||||
],
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"rawPath": "contracts",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/arr-and-contract-reporting-notes.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/arr-contract-first.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "invoices",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/revenue-reporting-policy.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/revenue-gross-to-net.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "arr_movements",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/discount-expiration.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "arr_movements",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/retention-and-nrr-definition-notes.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/bi/account_retention.view.lkml",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/nrr-retention.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "plans",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/sales-ops-segmentation-guide.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/segment-classification.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/activation-policy-decision-record.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/activation-policy.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "purchase_requests",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/procurement-workflows.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/customer-health-playbook.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "support_tickets",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/customer-health-scoring.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/support-escalation-runbook.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/support-escalation.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/notion/analyst-onboarding.md",
|
||||
"artifactKind": "wiki",
|
||||
"artifactKey": "knowledge/global/internal-test-exclusion.md",
|
||||
"actionType": "wiki_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "accounts",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "raw-sources/dbt/schema.yml",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.accounts",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "contracts",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.contracts",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "invoices",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.invoices",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "arr_movements",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.arr_movements",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "purchase_requests",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.purchase_requests",
|
||||
"actionType": "sl_written"
|
||||
},
|
||||
{
|
||||
"rawPath": "support_tickets",
|
||||
"artifactKind": "sl",
|
||||
"artifactKey": "orbit_demo.support_tickets",
|
||||
"actionType": "sl_written"
|
||||
}
|
||||
],
|
||||
"transcripts": [
|
||||
{
|
||||
"unitKey": "revenue-and-contracts",
|
||||
"path": "transcripts/revenue-and-contracts.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "retention-and-segments",
|
||||
"path": "transcripts/retention-and-segments.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "procurement-and-activation",
|
||||
"path": "transcripts/procurement-and-activation.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "support-and-health",
|
||||
"path": "transcripts/support-and-health.jsonl",
|
||||
"toolCallCount": 5,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write",
|
||||
"sl_write_source"
|
||||
]
|
||||
},
|
||||
{
|
||||
"unitKey": "governance-and-exclusions",
|
||||
"path": "transcripts/governance-and-exclusions.jsonl",
|
||||
"toolCallCount": 2,
|
||||
"errorCount": 0,
|
||||
"toolNames": [
|
||||
"wiki_write"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"id": "demo-seeded-report",
|
||||
"runId": "demo-seeded-orbit",
|
||||
"connectionId": "orbit_demo",
|
||||
"mode": "seeded",
|
||||
"status": "complete",
|
||||
"createdAt": "2026-05-06T00:00:00.000Z",
|
||||
"summary": {
|
||||
"sources": {
|
||||
"warehouse": {
|
||||
"tables": 8,
|
||||
"rows": 11234
|
||||
},
|
||||
"dbt": {
|
||||
"models": 3,
|
||||
"sources": 8
|
||||
},
|
||||
"bi": {
|
||||
"explores": 5,
|
||||
"dashboards": 2,
|
||||
"views": 5
|
||||
},
|
||||
"notion": {
|
||||
"pages": 8
|
||||
}
|
||||
},
|
||||
"generated": {
|
||||
"semanticLayerSources": 6,
|
||||
"knowledgePages": 10,
|
||||
"provenanceLinks": 23
|
||||
},
|
||||
"metadata": {
|
||||
"mode": "seeded",
|
||||
"origin": "packaged",
|
||||
"llmCalls": 0,
|
||||
"timing": "prebuilt",
|
||||
"source": "packaged-orbit-demo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
name: accounts
|
||||
table: accounts
|
||||
description: Customer accounts with industry, region, lifecycle, and internal/test flags.
|
||||
grain:
|
||||
- account_id
|
||||
columns:
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: parent_account_id
|
||||
type: string
|
||||
- name: account_name
|
||||
type: string
|
||||
- name: domain
|
||||
type: string
|
||||
- name: industry
|
||||
type: string
|
||||
- name: sales_region
|
||||
type: string
|
||||
- name: size_band
|
||||
type: string
|
||||
- name: lifecycle_status
|
||||
type: string
|
||||
- name: is_internal
|
||||
type: boolean
|
||||
- name: is_test
|
||||
type: boolean
|
||||
- name: created_at
|
||||
type: time
|
||||
joins:
|
||||
- to: contracts
|
||||
"on": "account_id = contracts.account_id"
|
||||
relationship: one_to_many
|
||||
- to: purchase_requests
|
||||
"on": "account_id = purchase_requests.account_id"
|
||||
relationship: one_to_many
|
||||
measures:
|
||||
- name: account_count
|
||||
expr: "count(distinct account_id)"
|
||||
- name: enterprise_count
|
||||
expr: "count(distinct account_id)"
|
||||
filter: "size_band = 'enterprise'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
name: arr_movements
|
||||
table: arr_movements
|
||||
description: ARR movement ledger for expansion, contraction, churn, and reactivation analysis.
|
||||
grain:
|
||||
- arr_movement_id
|
||||
columns:
|
||||
- name: arr_movement_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: parent_account_id
|
||||
type: string
|
||||
- name: contract_id
|
||||
type: string
|
||||
- name: movement_date
|
||||
type: time
|
||||
- name: movement_type
|
||||
type: string
|
||||
- name: movement_reason
|
||||
type: string
|
||||
- name: arr_delta_cents
|
||||
type: number
|
||||
- name: starting_arr_cents
|
||||
type: number
|
||||
- name: ending_arr_cents
|
||||
type: number
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: movement_count
|
||||
expr: "count(*)"
|
||||
- name: net_arr_delta
|
||||
expr: "sum(arr_delta_cents) / 100.0"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
name: contracts
|
||||
table: contracts
|
||||
description: Subscription contracts with ARR, plan, renewal, and status details.
|
||||
grain:
|
||||
- contract_id
|
||||
columns:
|
||||
- name: contract_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: parent_account_id
|
||||
type: string
|
||||
- name: plan_id
|
||||
type: string
|
||||
- name: contract_arr_cents
|
||||
type: number
|
||||
- name: booked_arr_cents
|
||||
type: number
|
||||
- name: start_date
|
||||
type: time
|
||||
- name: end_date
|
||||
type: time
|
||||
- name: status
|
||||
type: string
|
||||
- name: renewal_type
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: contract_count
|
||||
expr: "count(distinct contract_id)"
|
||||
- name: total_arr
|
||||
expr: "sum(contract_arr_cents) / 100.0"
|
||||
filter: "status = 'active'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
name: invoices
|
||||
table: invoices
|
||||
description: Billing invoices with payment status and revenue-recognition dates.
|
||||
grain:
|
||||
- invoice_id
|
||||
columns:
|
||||
- name: invoice_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: subscription_id
|
||||
type: string
|
||||
- name: invoice_date
|
||||
type: time
|
||||
- name: paid_at
|
||||
type: time
|
||||
- name: status
|
||||
type: string
|
||||
- name: currency
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: invoice_count
|
||||
expr: "count(*)"
|
||||
- name: paid_invoice_count
|
||||
expr: "count(*)"
|
||||
filter: "status = 'paid'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
name: purchase_requests
|
||||
table: purchase_requests
|
||||
description: Procurement workflow requests with requester, status, supplier, and spend fields.
|
||||
grain:
|
||||
- purchase_request_id
|
||||
columns:
|
||||
- name: purchase_request_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: requester_user_id
|
||||
type: string
|
||||
- name: created_at
|
||||
type: time
|
||||
- name: status
|
||||
type: string
|
||||
- name: amount_cents
|
||||
type: number
|
||||
- name: supplier_id
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: request_count
|
||||
expr: "count(*)"
|
||||
- name: approved_spend
|
||||
expr: "sum(amount_cents) / 100.0"
|
||||
filter: "status = 'approved'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
name: support_tickets
|
||||
table: support_tickets
|
||||
description: Customer support tickets with severity, category, status, and resolution tracking.
|
||||
grain:
|
||||
- support_ticket_id
|
||||
columns:
|
||||
- name: support_ticket_id
|
||||
type: string
|
||||
- name: account_id
|
||||
type: string
|
||||
- name: requester_user_id
|
||||
type: string
|
||||
- name: severity
|
||||
type: string
|
||||
- name: category
|
||||
type: string
|
||||
- name: status
|
||||
type: string
|
||||
- name: created_at
|
||||
type: time
|
||||
- name: resolved_at
|
||||
type: time
|
||||
- name: owner_user_id
|
||||
type: string
|
||||
joins:
|
||||
- to: accounts
|
||||
"on": "account_id = accounts.account_id"
|
||||
relationship: many_to_one
|
||||
measures:
|
||||
- name: ticket_count
|
||||
expr: "count(*)"
|
||||
- name: open_ticket_count
|
||||
expr: "count(*)"
|
||||
filter: "status != 'resolved'"
|
||||
segments:
|
||||
- name: external_only
|
||||
expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"
|
||||
72
packages/cli/package.json
Normal file
72
packages/cli/package.json
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"name": "@klo/cli",
|
||||
"version": "0.0.0-private",
|
||||
"description": "CLI wrapper for klo context packages",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"klo": "./dist/bin.js"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"assets"
|
||||
],
|
||||
"scripts": {
|
||||
"assets:demo": "node scripts/build-demo-assets.mjs",
|
||||
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node ../../scripts/prepare-cli-bin.mjs",
|
||||
"smoke": "vitest run src/standalone-smoke.test.ts src/example-smoke.test.ts --testTimeout 30000",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.3.0",
|
||||
"@commander-js/extra-typings": "14.0.0",
|
||||
"@klo/connector-bigquery": "workspace:*",
|
||||
"@klo/connector-clickhouse": "workspace:*",
|
||||
"@klo/connector-mysql": "workspace:*",
|
||||
"@klo/connector-postgres": "workspace:*",
|
||||
"@klo/connector-posthog": "workspace:*",
|
||||
"@klo/connector-snowflake": "workspace:*",
|
||||
"@klo/connector-sqlite": "workspace:*",
|
||||
"@klo/connector-sqlserver": "workspace:*",
|
||||
"@klo/context": "workspace:*",
|
||||
"@klo/llm": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"commander": "14.0.3",
|
||||
"ink": "^7.0.1",
|
||||
"react": "^19.2.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/kaelio/ktx.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/kaelio/ktx/issues"
|
||||
},
|
||||
"homepage": "https://github.com/kaelio/ktx#readme"
|
||||
}
|
||||
954
packages/cli/scripts/build-demo-assets.mjs
Normal file
954
packages/cli/scripts/build-demo-assets.mjs
Normal file
|
|
@ -0,0 +1,954 @@
|
|||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access, copyFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const repoRoot = resolve(packageRoot, '../..');
|
||||
const defaultDemoSource = resolve(repoRoot, '../../../orbit-demo-source');
|
||||
const sourceRoot = resolve(process.env.KLO_DEMO_SOURCE_DIR ?? defaultDemoSource);
|
||||
const assetDir = join(packageRoot, 'assets/demo/orbit');
|
||||
const dbPath = join(assetDir, 'demo.db');
|
||||
const exampleDbtProjectDir = ['dbt', `${'kae'}lio_demo`].join('/');
|
||||
const packagedDemoSource = 'packaged-orbit-demo';
|
||||
|
||||
const warehouseTables = [
|
||||
'accounts',
|
||||
'contracts',
|
||||
'users',
|
||||
'invoices',
|
||||
'arr_movements',
|
||||
'support_tickets',
|
||||
'purchase_requests',
|
||||
'plans',
|
||||
];
|
||||
|
||||
const copyFiles = [
|
||||
[`${exampleDbtProjectDir}/dbt_project.yml`, 'raw-sources/dbt/dbt_project.yml'],
|
||||
[`${exampleDbtProjectDir}/models/sources.yml`, 'raw-sources/dbt/sources.yml'],
|
||||
[`${exampleDbtProjectDir}/models/schema.yml`, 'raw-sources/dbt/schema.yml'],
|
||||
[`${exampleDbtProjectDir}/models/marts/mart_revenue_daily.sql`, 'raw-sources/dbt/models/marts/mart_revenue_daily.sql'],
|
||||
[`${exampleDbtProjectDir}/models/marts/mart_arr_daily.sql`, 'raw-sources/dbt/models/marts/mart_arr_daily.sql'],
|
||||
[
|
||||
`${exampleDbtProjectDir}/models/marts/mart_customer_health.sql`,
|
||||
'raw-sources/dbt/models/marts/mart_customer_health.sql',
|
||||
],
|
||||
['views/account_retention.view.lkml', 'raw-sources/bi/account_retention.view.lkml'],
|
||||
['views/arr_daily.view.lkml', 'raw-sources/bi/arr_daily.view.lkml'],
|
||||
['views/customer_health.view.lkml', 'raw-sources/bi/customer_health.view.lkml'],
|
||||
['views/procurement_activity.view.lkml', 'raw-sources/bi/procurement_activity.view.lkml'],
|
||||
['views/revenue_daily.view.lkml', 'raw-sources/bi/revenue_daily.view.lkml'],
|
||||
['dashboards/revenue_exec.dashboard.lookml', 'raw-sources/bi/revenue_exec.dashboard.lookml'],
|
||||
['dashboards/retention_exec_q1.dashboard.lookml', 'raw-sources/bi/retention_exec_q1.dashboard.lookml'],
|
||||
['notion/export/pages/revenue-reporting-policy.md', 'raw-sources/notion/revenue-reporting-policy.md'],
|
||||
['notion/export/pages/sales-ops-segmentation-guide.md', 'raw-sources/notion/sales-ops-segmentation-guide.md'],
|
||||
['notion/export/pages/customer-health-playbook.md', 'raw-sources/notion/customer-health-playbook.md'],
|
||||
['notion/export/pages/support-escalation-runbook.md', 'raw-sources/notion/support-escalation-runbook.md'],
|
||||
[
|
||||
'notion/export/pages/arr-and-contract-reporting-notes.md',
|
||||
'raw-sources/notion/arr-and-contract-reporting-notes.md',
|
||||
],
|
||||
[
|
||||
'notion/export/pages/activation-policy-decision-record.md',
|
||||
'raw-sources/notion/activation-policy-decision-record.md',
|
||||
],
|
||||
[
|
||||
'notion/export/pages/retention-and-nrr-definition-notes.md',
|
||||
'raw-sources/notion/retention-and-nrr-definition-notes.md',
|
||||
],
|
||||
['notion/export/pages/analyst-onboarding.md', 'raw-sources/notion/analyst-onboarding.md'],
|
||||
];
|
||||
|
||||
const semanticLayerTables = [
|
||||
'accounts',
|
||||
'contracts',
|
||||
'invoices',
|
||||
'arr_movements',
|
||||
'purchase_requests',
|
||||
'support_tickets',
|
||||
];
|
||||
|
||||
const semanticLayerDescriptions = {
|
||||
accounts: 'Customer accounts with industry, region, lifecycle, and internal/test flags.',
|
||||
contracts: 'Subscription contracts with ARR, plan, renewal, and status details.',
|
||||
invoices: 'Billing invoices with payment status and revenue-recognition dates.',
|
||||
arr_movements: 'ARR movement ledger for expansion, contraction, churn, and reactivation analysis.',
|
||||
purchase_requests: 'Procurement workflow requests with requester, status, supplier, and spend fields.',
|
||||
support_tickets: 'Customer support tickets with severity, category, status, and resolution tracking.',
|
||||
};
|
||||
|
||||
const semanticLayerMeasures = {
|
||||
accounts: [
|
||||
{ name: 'account_count', expr: 'count(distinct account_id)' },
|
||||
{ name: 'enterprise_count', expr: 'count(distinct account_id)', filter: "size_band = 'enterprise'" },
|
||||
],
|
||||
contracts: [
|
||||
{ name: 'contract_count', expr: 'count(distinct contract_id)' },
|
||||
{ name: 'total_arr', expr: 'sum(contract_arr_cents) / 100.0', filter: "status = 'active'" },
|
||||
],
|
||||
invoices: [
|
||||
{ name: 'invoice_count', expr: 'count(*)' },
|
||||
{ name: 'paid_invoice_count', expr: 'count(*)', filter: "status = 'paid'" },
|
||||
],
|
||||
arr_movements: [
|
||||
{ name: 'movement_count', expr: 'count(*)' },
|
||||
{ name: 'net_arr_delta', expr: 'sum(arr_delta_cents) / 100.0' },
|
||||
],
|
||||
purchase_requests: [
|
||||
{ name: 'request_count', expr: 'count(*)' },
|
||||
{ name: 'approved_spend', expr: 'sum(amount_cents) / 100.0', filter: "status = 'approved'" },
|
||||
],
|
||||
support_tickets: [
|
||||
{ name: 'ticket_count', expr: 'count(*)' },
|
||||
{ name: 'open_ticket_count', expr: 'count(*)', filter: "status != 'resolved'" },
|
||||
],
|
||||
};
|
||||
|
||||
const knowledgePages = [
|
||||
{
|
||||
file: 'arr-contract-first.md',
|
||||
summary: 'ARR uses contract-first precedence before subscription-derived revenue.',
|
||||
tags: ['finance', 'arr', 'revenue'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.contracts', 'orbit_demo.arr_movements'],
|
||||
body: [
|
||||
'ARR is calculated from active recurring contract ARR before falling back to subscription-derived revenue.',
|
||||
'Do not double-count subscription MRR when an active contract row covers the same account and period.',
|
||||
'Exclude cancelled contracts ending before the metric date, future-starting contracts, internal accounts, and test accounts.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'revenue-gross-to-net.md',
|
||||
summary: 'Gross-to-net revenue reconciles paid invoices, credits, and refunds.',
|
||||
tags: ['finance', 'revenue'],
|
||||
refs: ['arr-contract-first'],
|
||||
slRefs: ['orbit_demo.invoices'],
|
||||
body: [
|
||||
'Gross revenue starts from paid invoice activity. Net revenue subtracts credits and successful refunds in the month they are recorded.',
|
||||
'Exclude unpaid, void, draft, failed, internal, and test-account invoice activity from canonical revenue reporting.',
|
||||
'February 2026 has an elevated refund event captured in the source notes and revenue dashboard.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'discount-expiration.md',
|
||||
summary: 'Discount expirations are tracked separately from organic contraction.',
|
||||
tags: ['finance', 'retention'],
|
||||
refs: ['arr-contract-first', 'nrr-retention'],
|
||||
slRefs: ['orbit_demo.contracts', 'orbit_demo.arr_movements'],
|
||||
body: [
|
||||
'Discount expiration events identify pricing changes when negotiated discounts end.',
|
||||
'Track these separately from organic contraction so board reporting can split pricing-driven and usage-driven changes.',
|
||||
'Use movement_reason on arr_movements when separating discount expiration from churn or seat-reduction events.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'nrr-retention.md',
|
||||
summary: 'NRR is calculated at parent-account grain by calendar quarter.',
|
||||
tags: ['analytics', 'retention', 'nrr'],
|
||||
refs: ['arr-contract-first'],
|
||||
slRefs: ['orbit_demo.arr_movements', 'orbit_demo.accounts'],
|
||||
body: [
|
||||
'Net Revenue Retention uses parent-account rollups by calendar quarter.',
|
||||
'The formula is starting ARR plus expansion minus contraction and churn, divided by starting ARR.',
|
||||
'Exclude parent accounts with zero starting ARR, new business, reactivations, and internal/test accounts from the denominator.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'segment-classification.md',
|
||||
summary: 'Account segments derive from plan normalization and effective-dated mapping.',
|
||||
tags: ['sales-ops', 'segmentation'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.accounts', 'orbit_demo.contracts'],
|
||||
body: [
|
||||
'Account segment labels combine plan_code, canonical_plan_code, and size_band fields.',
|
||||
'Historical plan code pro_plus maps to growth for current segment analysis.',
|
||||
'Use the mapping active at the metric date when segment definitions change over time.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'activation-policy.md',
|
||||
summary: 'Account activation policy changed on January 15, 2026.',
|
||||
tags: ['growth', 'activation', 'policy'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.accounts', 'orbit_demo.purchase_requests'],
|
||||
body: [
|
||||
'Before January 15, 2026, activation meant first requester login.',
|
||||
'On and after January 15, 2026, activation requires an approved purchase request and at least three activated requesters.',
|
||||
'Always separate pre-policy and post-policy cohorts when comparing activation rates.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'procurement-workflows.md',
|
||||
summary: 'Procurement workflow activity measures active requesters and qualifying actions.',
|
||||
tags: ['product', 'procurement'],
|
||||
refs: ['activation-policy'],
|
||||
slRefs: ['orbit_demo.purchase_requests'],
|
||||
body: [
|
||||
'Weekly active requesters counts distinct non-internal requesters with a qualifying procurement action in the calendar week.',
|
||||
'Qualifying actions include purchase request creation, approval decisions, supplier invites, and purchase-order creation.',
|
||||
'Purchase-request comments and short sessions are excluded from the canonical requester activity metric.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'customer-health-scoring.md',
|
||||
summary: 'Customer health combines support severity and procurement activity.',
|
||||
tags: ['customer-success', 'health', 'churn-risk'],
|
||||
refs: ['nrr-retention'],
|
||||
slRefs: ['orbit_demo.support_tickets', 'orbit_demo.purchase_requests', 'orbit_demo.accounts'],
|
||||
body: [
|
||||
'High-risk accounts have multiple recent high-severity tickets or no recent procurement activity on growth and enterprise plans.',
|
||||
'Medium risk captures partial support pressure or a material month-over-month decline in procurement activity.',
|
||||
'Internal and test accounts are excluded from customer health scoring.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'support-escalation.md',
|
||||
summary: 'Support escalation tiers map ticket severity to SLA targets.',
|
||||
tags: ['support', 'sla'],
|
||||
refs: ['customer-health-scoring'],
|
||||
slRefs: ['orbit_demo.support_tickets'],
|
||||
body: [
|
||||
'Critical support tickets require immediate response and on-call escalation.',
|
||||
'High severity tickets should receive first response within four business hours.',
|
||||
'Resolution time is measured from created_at to resolved_at and only applies to resolved tickets.',
|
||||
],
|
||||
},
|
||||
{
|
||||
file: 'internal-test-exclusion.md',
|
||||
summary: 'Canonical metrics exclude internal and test accounts and users.',
|
||||
tags: ['data-quality', 'governance'],
|
||||
refs: [],
|
||||
slRefs: ['orbit_demo.accounts'],
|
||||
body: [
|
||||
'All canonical customer metrics exclude rows marked as internal or test fixtures.',
|
||||
'This exclusion applies at both account and user grain when joining procurement, support, and revenue activity.',
|
||||
'If a metric unexpectedly increases, check whether new internal or test accounts were created without proper flags.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const provenanceLinks = [
|
||||
['wiki', 'knowledge/global/arr-contract-first.md', 'warehouse', 'contracts', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/arr-contract-first.md',
|
||||
'notion',
|
||||
'raw-sources/notion/arr-and-contract-reporting-notes.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/revenue-gross-to-net.md', 'warehouse', 'invoices', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/revenue-gross-to-net.md',
|
||||
'notion',
|
||||
'raw-sources/notion/revenue-reporting-policy.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/discount-expiration.md', 'warehouse', 'arr_movements', 'describes', 1],
|
||||
['wiki', 'knowledge/global/nrr-retention.md', 'warehouse', 'arr_movements', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/nrr-retention.md',
|
||||
'notion',
|
||||
'raw-sources/notion/retention-and-nrr-definition-notes.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/nrr-retention.md', 'bi', 'raw-sources/bi/account_retention.view.lkml', 'derived_from', 0.85],
|
||||
['wiki', 'knowledge/global/segment-classification.md', 'warehouse', 'plans', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/segment-classification.md',
|
||||
'notion',
|
||||
'raw-sources/notion/sales-ops-segmentation-guide.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/activation-policy.md',
|
||||
'notion',
|
||||
'raw-sources/notion/activation-policy-decision-record.md',
|
||||
'derived_from',
|
||||
0.95,
|
||||
],
|
||||
['wiki', 'knowledge/global/procurement-workflows.md', 'warehouse', 'purchase_requests', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/customer-health-scoring.md',
|
||||
'notion',
|
||||
'raw-sources/notion/customer-health-playbook.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
['wiki', 'knowledge/global/customer-health-scoring.md', 'warehouse', 'support_tickets', 'describes', 1],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/support-escalation.md',
|
||||
'notion',
|
||||
'raw-sources/notion/support-escalation-runbook.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
[
|
||||
'wiki',
|
||||
'knowledge/global/internal-test-exclusion.md',
|
||||
'notion',
|
||||
'raw-sources/notion/analyst-onboarding.md',
|
||||
'derived_from',
|
||||
0.9,
|
||||
],
|
||||
['sl', 'orbit_demo.accounts', 'warehouse', 'accounts', 'models', 1],
|
||||
['sl', 'orbit_demo.accounts', 'dbt', 'raw-sources/dbt/schema.yml', 'inherits_from', 0.95],
|
||||
['sl', 'orbit_demo.contracts', 'warehouse', 'contracts', 'models', 1],
|
||||
['sl', 'orbit_demo.invoices', 'warehouse', 'invoices', 'models', 1],
|
||||
['sl', 'orbit_demo.arr_movements', 'warehouse', 'arr_movements', 'models', 1],
|
||||
['sl', 'orbit_demo.purchase_requests', 'warehouse', 'purchase_requests', 'models', 1],
|
||||
['sl', 'orbit_demo.support_tickets', 'warehouse', 'support_tickets', 'models', 1],
|
||||
].map(([artifactKind, artifactKey, sourceKind, sourcePath, relationship, confidence], index) => ({
|
||||
id: `link-${String(index + 1).padStart(3, '0')}`,
|
||||
artifactKind,
|
||||
artifactKey,
|
||||
sourceKind,
|
||||
sourcePath,
|
||||
relationship,
|
||||
confidence,
|
||||
}));
|
||||
|
||||
async function pathExists(path) {
|
||||
try {
|
||||
await access(path, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertReadable(path, label) {
|
||||
if (!(await pathExists(path))) {
|
||||
throw new Error(
|
||||
`${label} not found at ${path}. Set KLO_DEMO_SOURCE_DIR to the Orbit demo source directory.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const values = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
} else if (char === ',' && !quoted) {
|
||||
values.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current);
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseCsv(raw) {
|
||||
const lines = raw.trimEnd().split(/\r?\n/);
|
||||
const headers = parseCsvLine(lines[0]);
|
||||
const rows = lines.slice(1).map((line) => parseCsvLine(line));
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
function quoteIdentifier(value) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function inferColumnType(column) {
|
||||
if (column.startsWith('is_')) {
|
||||
return 'boolean';
|
||||
}
|
||||
if (column.endsWith('_at') || column.endsWith('_date') || column === 'retired_at') {
|
||||
return 'time';
|
||||
}
|
||||
if (column.endsWith('_cents') || column.endsWith('_count')) {
|
||||
return 'number';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function renderKnowledgePage(page) {
|
||||
const refs = page.refs.length > 0 ? ['refs:', ...page.refs.map((ref) => ` - ${ref}`)] : ['refs: []'];
|
||||
const slRefs = page.slRefs.map((ref) => ` - ${ref}`).join('\n');
|
||||
return [
|
||||
'---',
|
||||
`summary: ${page.summary}`,
|
||||
'tags:',
|
||||
...page.tags.map((tag) => ` - ${tag}`),
|
||||
...refs,
|
||||
'sl_refs:',
|
||||
slRefs,
|
||||
'usage_mode: auto',
|
||||
'---',
|
||||
'',
|
||||
page.body.join('\n\n'),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderMeasure(measure) {
|
||||
const lines = [` - name: ${measure.name}`, ` expr: ${JSON.stringify(measure.expr)}`];
|
||||
if (measure.filter) {
|
||||
lines.push(` filter: ${JSON.stringify(measure.filter)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function renderSemanticLayerSource(table) {
|
||||
const raw = await readFile(join(sourceRoot, 'database/seeds', `${table}.csv`), 'utf-8');
|
||||
const { headers } = parseCsv(raw);
|
||||
const primaryKey = headers[0];
|
||||
const joins =
|
||||
table === 'accounts'
|
||||
? [
|
||||
' - to: contracts',
|
||||
' "on": "account_id = contracts.account_id"',
|
||||
' relationship: one_to_many',
|
||||
' - to: purchase_requests',
|
||||
' "on": "account_id = purchase_requests.account_id"',
|
||||
' relationship: one_to_many',
|
||||
]
|
||||
: [' - to: accounts', ' "on": "account_id = accounts.account_id"', ' relationship: many_to_one'];
|
||||
|
||||
return [
|
||||
`name: ${table}`,
|
||||
`table: ${table}`,
|
||||
`description: ${semanticLayerDescriptions[table]}`,
|
||||
'grain:',
|
||||
` - ${primaryKey}`,
|
||||
'columns:',
|
||||
...headers.flatMap((header) => [` - name: ${header}`, ` type: ${inferColumnType(header)}`]),
|
||||
'joins:',
|
||||
...joins,
|
||||
'measures:',
|
||||
...semanticLayerMeasures[table].map(renderMeasure),
|
||||
'segments:',
|
||||
' - name: external_only',
|
||||
' expr: "coalesce(is_internal, 0) = 0 AND coalesce(is_test, 0) = 0"',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function writeWarehouse(db, rowCounts) {
|
||||
for (const table of warehouseTables) {
|
||||
const sourceCsv = join(sourceRoot, 'database/seeds', `${table}.csv`);
|
||||
const raw = await readFile(sourceCsv, 'utf-8');
|
||||
const { headers, rows } = parseCsv(raw);
|
||||
const columnsSql = headers.map((header) => `${quoteIdentifier(header)} TEXT`).join(', ');
|
||||
db.exec(`CREATE TABLE ${quoteIdentifier(table)} (${columnsSql});`);
|
||||
const placeholders = headers.map(() => '?').join(', ');
|
||||
const statement = db.prepare(`INSERT INTO ${quoteIdentifier(table)} VALUES (${placeholders})`);
|
||||
const insertAll = db.transaction((records) => {
|
||||
for (const record of records) {
|
||||
statement.run(record);
|
||||
}
|
||||
});
|
||||
insertAll(rows);
|
||||
rowCounts[table] = rows.length;
|
||||
await copyFile(sourceCsv, join(assetDir, 'raw-sources/warehouse', `${table}.csv`));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCuratedSourceFiles() {
|
||||
for (const [from, to] of copyFiles) {
|
||||
const destination = join(assetDir, to);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(join(sourceRoot, from), destination);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(relativePath, value) {
|
||||
const destination = join(assetDir, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
async function writeText(relativePath, value) {
|
||||
const destination = join(assetDir, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, value, 'utf-8');
|
||||
}
|
||||
|
||||
function buildActions() {
|
||||
return [
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/arr-contract-first.md',
|
||||
summary: 'ARR follows contract precedence with cancellation and discount caveats.',
|
||||
rawFiles: ['contracts', 'arr_movements', 'raw-sources/notion/arr-and-contract-reporting-notes.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/revenue-gross-to-net.md',
|
||||
summary: 'Invoice, refund, and revenue dashboard evidence reconcile gross to net revenue.',
|
||||
rawFiles: ['invoices', 'raw-sources/bi/revenue_exec.dashboard.lookml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/discount-expiration.md',
|
||||
summary: 'Discount expiration is separated from organic contraction for retention reporting.',
|
||||
rawFiles: ['contracts', 'arr_movements'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.contracts',
|
||||
summary: 'Contract grain with active ARR measures and account joins.',
|
||||
rawFiles: ['contracts', 'raw-sources/dbt/schema.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.invoices',
|
||||
summary: 'Invoice status measures tied to gross and net revenue reporting.',
|
||||
rawFiles: ['invoices', 'raw-sources/bi/revenue_daily.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.arr_movements',
|
||||
summary: 'ARR movement ledger for expansion, contraction, churn, and NRR.',
|
||||
rawFiles: ['arr_movements', 'raw-sources/bi/account_retention.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/nrr-retention.md',
|
||||
summary: 'NRR uses parent-account rollups and quarterly ARR movement windows.',
|
||||
rawFiles: ['accounts', 'arr_movements', 'raw-sources/notion/retention-and-nrr-definition-notes.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/segment-classification.md',
|
||||
summary: 'Segment labels come from plan mapping and sales-ops policy notes.',
|
||||
rawFiles: ['accounts', 'plans', 'raw-sources/notion/sales-ops-segmentation-guide.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.accounts',
|
||||
summary: 'Account dimensions with lifecycle, segment, and internal-test exclusions.',
|
||||
rawFiles: ['accounts', 'plans'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/activation-policy.md',
|
||||
summary: 'Activation policy changed on January 15, 2026 and is encoded for agents.',
|
||||
rawFiles: ['purchase_requests', 'users', 'raw-sources/notion/activation-policy-decision-record.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/procurement-workflows.md',
|
||||
summary: 'Procurement requester activity and approval events explain product usage.',
|
||||
rawFiles: ['purchase_requests', 'raw-sources/bi/procurement_activity.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.purchase_requests',
|
||||
summary: 'Procurement request facts with requester and approval-state measures.',
|
||||
rawFiles: ['purchase_requests'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/customer-health-scoring.md',
|
||||
summary: 'Customer health combines support severity, ARR exposure, and product usage.',
|
||||
rawFiles: ['support_tickets', 'raw-sources/notion/customer-health-playbook.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/support-escalation.md',
|
||||
summary: 'Escalation tiers map ticket severity to SLA expectations.',
|
||||
rawFiles: ['support_tickets', 'raw-sources/notion/support-escalation-runbook.md'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'support-and-health',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.support_tickets',
|
||||
summary: 'Support ticket facts with severity, status, and resolution-hour measures.',
|
||||
rawFiles: ['support_tickets'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'governance-and-exclusions',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/internal-test-exclusion.md',
|
||||
summary: 'Canonical metrics exclude internal and test accounts across source families.',
|
||||
rawFiles: ['raw-sources/notion/analyst-onboarding.md'],
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildReplay(provenance, transcripts) {
|
||||
return {
|
||||
memoryFlowReplaySchemaVersion: 1,
|
||||
replay: {
|
||||
runId: 'demo-seeded-orbit',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'demo-seeded-sync',
|
||||
reportId: 'demo-seeded-report',
|
||||
reportPath: 'reports/seeded-demo-report.json',
|
||||
errors: [],
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'seeded',
|
||||
origin: 'packaged',
|
||||
timing: 'prebuilt',
|
||||
capturedAt: '2026-05-06T00:00:00.000Z',
|
||||
sourceReportId: 'demo-seeded-report',
|
||||
sourceReportPath: 'reports/seeded-demo-report.json',
|
||||
fallbackReason: null,
|
||||
},
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_seeded', fileCount: 8 },
|
||||
{ type: 'source_acquired', adapter: 'dbt_descriptions', trigger: 'demo_seeded', fileCount: 6 },
|
||||
{ type: 'source_acquired', adapter: 'looker', trigger: 'demo_seeded', fileCount: 7 },
|
||||
{ type: 'source_acquired', adapter: 'notion', trigger: 'demo_seeded', fileCount: 8 },
|
||||
{ type: 'scope_detected', fingerprint: 'sqlite:orbit-demo' },
|
||||
{ type: 'raw_snapshot_written', syncId: 'demo-seeded-sync', rawFileCount: 29 },
|
||||
{ type: 'diff_computed', added: 29, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 5, workUnitCount: 5, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'revenue-and-contracts', skills: ['knowledge_capture', 'sl_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/arr-contract-first.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/revenue-gross-to-net.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/discount-expiration.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.contracts',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.invoices',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.arr_movements',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'revenue-and-contracts', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'retention-and-segments', skills: ['knowledge_capture', 'sl_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/nrr-retention.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/segment-classification.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'retention-and-segments',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.accounts',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'retention-and-segments', status: 'success' },
|
||||
{
|
||||
type: 'work_unit_started',
|
||||
unitKey: 'procurement-and-activation',
|
||||
skills: ['knowledge_capture', 'sl_capture'],
|
||||
stepBudget: 40,
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/activation-policy.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/procurement-workflows.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'procurement-and-activation',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.purchase_requests',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'procurement-and-activation', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'support-and-health', skills: ['knowledge_capture', 'sl_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/customer-health-scoring.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'support-and-health',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/support-escalation.md',
|
||||
},
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'support-and-health',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'orbit_demo.support_tickets',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'support-and-health', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'governance-and-exclusions', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'governance-and-exclusions',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/internal-test-exclusion.md',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'governance-and-exclusions', status: 'success' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 0, fallbackCount: 0 },
|
||||
{ type: 'saved', commitSha: 'demo-seeded', wikiCount: 10, slCount: 6 },
|
||||
{ type: 'provenance_recorded', rowCount: provenance.length },
|
||||
{ type: 'report_created', runId: 'demo-seeded-orbit', reportPath: 'reports/seeded-demo-report.json' },
|
||||
],
|
||||
plannedWorkUnits: [
|
||||
{
|
||||
unitKey: 'revenue-and-contracts',
|
||||
rawFiles: ['contracts', 'invoices', 'arr_movements'],
|
||||
peerFileCount: 3,
|
||||
dependencyCount: 3,
|
||||
},
|
||||
{
|
||||
unitKey: 'retention-and-segments',
|
||||
rawFiles: ['accounts', 'plans'],
|
||||
peerFileCount: 2,
|
||||
dependencyCount: 2,
|
||||
},
|
||||
{
|
||||
unitKey: 'procurement-and-activation',
|
||||
rawFiles: ['purchase_requests', 'users'],
|
||||
peerFileCount: 2,
|
||||
dependencyCount: 2,
|
||||
},
|
||||
{ unitKey: 'support-and-health', rawFiles: ['support_tickets'], peerFileCount: 1, dependencyCount: 1 },
|
||||
{
|
||||
unitKey: 'governance-and-exclusions',
|
||||
rawFiles: ['notion/export/pages/analyst-onboarding.md'],
|
||||
peerFileCount: 1,
|
||||
dependencyCount: 0,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
actions: buildActions(),
|
||||
provenance,
|
||||
transcripts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeGeneratedContext(rowCounts) {
|
||||
for (const page of knowledgePages) {
|
||||
await writeText(join('knowledge/global', page.file), renderKnowledgePage(page));
|
||||
}
|
||||
|
||||
for (const table of semanticLayerTables) {
|
||||
await writeText(join('semantic-layer/orbit_demo', `${table}.yaml`), await renderSemanticLayerSource(table));
|
||||
}
|
||||
|
||||
const provenance = provenanceLinks.map((link) => ({
|
||||
rawPath: link.sourcePath,
|
||||
artifactKind: link.artifactKind,
|
||||
artifactKey: link.artifactKey,
|
||||
actionType: link.artifactKind === 'sl' ? 'sl_written' : 'wiki_written',
|
||||
}));
|
||||
const transcripts = [
|
||||
'revenue-and-contracts',
|
||||
'retention-and-segments',
|
||||
'procurement-and-activation',
|
||||
'support-and-health',
|
||||
'governance-and-exclusions',
|
||||
].map((unitKey) => ({
|
||||
unitKey,
|
||||
path: `transcripts/${unitKey}.jsonl`,
|
||||
toolCallCount: unitKey === 'governance-and-exclusions' ? 2 : 5,
|
||||
errorCount: 0,
|
||||
toolNames: unitKey === 'governance-and-exclusions' ? ['wiki_write'] : ['wiki_write', 'sl_write_source'],
|
||||
}));
|
||||
|
||||
await writeJson('links/provenance.json', provenanceLinks);
|
||||
await writeJson('reports/seeded-demo-report.json', {
|
||||
id: 'demo-seeded-report',
|
||||
runId: 'demo-seeded-orbit',
|
||||
connectionId: 'orbit_demo',
|
||||
mode: 'seeded',
|
||||
status: 'complete',
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
summary: {
|
||||
sources: {
|
||||
warehouse: { tables: 8, rows: Object.values(rowCounts).reduce((sum, count) => sum + count, 0) },
|
||||
dbt: { models: 3, sources: 8 },
|
||||
bi: { explores: 5, dashboards: 2, views: 5 },
|
||||
notion: { pages: 8 },
|
||||
},
|
||||
generated: {
|
||||
semanticLayerSources: 6,
|
||||
knowledgePages: 10,
|
||||
provenanceLinks: provenanceLinks.length,
|
||||
},
|
||||
metadata: {
|
||||
mode: 'seeded',
|
||||
origin: 'packaged',
|
||||
llmCalls: 0,
|
||||
timing: 'prebuilt',
|
||||
source: packagedDemoSource,
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeJson('manifest.json', {
|
||||
demoAssetSchemaVersion: 2,
|
||||
name: 'orbit',
|
||||
displayName: 'Orbit Demo',
|
||||
mode: 'seeded',
|
||||
sqliteDatabase: 'demo.db',
|
||||
replay: 'replay.memory-flow.v1.json',
|
||||
report: 'reports/seeded-demo-report.json',
|
||||
source: packagedDemoSource,
|
||||
sources: {
|
||||
warehouse: { label: 'Warehouse', path: 'demo.db', tables: 8, rowCounts },
|
||||
dbt: { label: 'dbt', path: 'raw-sources/dbt', models: 3, sourceTables: 8 },
|
||||
bi: { label: 'BI', path: 'raw-sources/bi', explores: 5, dashboards: 2 },
|
||||
notion: { label: 'Notion', path: 'raw-sources/notion', pages: 8 },
|
||||
},
|
||||
generated: {
|
||||
semanticLayer: { path: 'semantic-layer/orbit_demo', sourceCount: 6 },
|
||||
knowledge: { path: 'knowledge/global', pageCount: 10 },
|
||||
links: { path: 'links', linkCount: provenanceLinks.length },
|
||||
},
|
||||
});
|
||||
await writeJson('replay.memory-flow.v1.json', buildReplay(provenance, transcripts));
|
||||
}
|
||||
|
||||
await assertReadable(join(sourceRoot, 'database/seeds/accounts.csv'), `${packagedDemoSource} seed data`);
|
||||
await assertReadable(join(sourceRoot, `${exampleDbtProjectDir}/models/schema.yml`), `${packagedDemoSource} dbt schema`);
|
||||
await assertReadable(join(sourceRoot, 'views/revenue_daily.view.lkml'), `${packagedDemoSource} LookML views`);
|
||||
await assertReadable(
|
||||
join(sourceRoot, 'notion/export/pages/revenue-reporting-policy.md'),
|
||||
`${packagedDemoSource} Notion export`,
|
||||
);
|
||||
|
||||
await rm(assetDir, { recursive: true, force: true });
|
||||
for (const relativeDir of [
|
||||
'raw-sources/warehouse',
|
||||
'raw-sources/dbt/models/marts',
|
||||
'raw-sources/bi',
|
||||
'raw-sources/notion',
|
||||
'semantic-layer/orbit_demo',
|
||||
'knowledge/global',
|
||||
'links',
|
||||
'reports',
|
||||
]) {
|
||||
await mkdir(join(assetDir, relativeDir), { recursive: true });
|
||||
}
|
||||
|
||||
const rowCounts = {};
|
||||
await rm(dbPath, { force: true });
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
await writeWarehouse(db, rowCounts);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
await copyCuratedSourceFiles();
|
||||
await writeGeneratedContext(rowCounts);
|
||||
|
||||
const dbStat = await stat(dbPath);
|
||||
if (dbStat.size >= 10 * 1024 * 1024) {
|
||||
throw new Error(`Seeded demo SQLite bundle is too large: ${dbStat.size} bytes`);
|
||||
}
|
||||
108
packages/cli/src/agent-runtime.test.ts
Normal file
108
packages/cli/src/agent-runtime.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
KLO_AGENT_MAX_ROWS_CAP,
|
||||
createKloAgentRuntime,
|
||||
parseAgentMaxRows,
|
||||
readAgentJsonFile,
|
||||
writeAgentJson,
|
||||
writeAgentJsonError,
|
||||
} from './agent-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => (stdout += chunk) },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent runtime helpers', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-agent-runtime-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes JSON success and error envelopes without color or spinners', () => {
|
||||
const successIo = makeIo();
|
||||
const errorIo = makeIo();
|
||||
|
||||
writeAgentJson(successIo.io, { ok: true });
|
||||
writeAgentJsonError(errorIo.io, 'missing source', { code: 'NOT_FOUND' });
|
||||
|
||||
expect(JSON.parse(successIo.stdout())).toEqual({ ok: true });
|
||||
expect(successIo.stderr()).toBe('');
|
||||
expect(JSON.parse(errorIo.stderr())).toEqual({
|
||||
ok: false,
|
||||
error: { message: 'missing source', code: 'NOT_FOUND' },
|
||||
});
|
||||
expect(errorIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('reads JSON query files as objects', async () => {
|
||||
const path = join(tempDir, 'query.json');
|
||||
await writeFile(path, '{"measures":["revenue"],"limit":50}', 'utf-8');
|
||||
|
||||
await expect(readAgentJsonFile(path)).resolves.toEqual({ measures: ['revenue'], limit: 50 });
|
||||
});
|
||||
|
||||
it('rejects non-object JSON query files', async () => {
|
||||
const path = join(tempDir, 'query.json');
|
||||
await writeFile(path, '["revenue"]', 'utf-8');
|
||||
|
||||
await expect(readAgentJsonFile(path)).rejects.toThrow('must contain a JSON object');
|
||||
});
|
||||
|
||||
it('requires positive row limits and enforces the agent cap', () => {
|
||||
expect(parseAgentMaxRows(100)).toBe(100);
|
||||
expect(() => parseAgentMaxRows(undefined)).toThrow('maxRows is required');
|
||||
expect(() => parseAgentMaxRows(0)).toThrow('positive integer');
|
||||
expect(() => parseAgentMaxRows(KLO_AGENT_MAX_ROWS_CAP + 1)).toThrow(String(KLO_AGENT_MAX_ROWS_CAP));
|
||||
});
|
||||
|
||||
it('constructs local context ports with semantic compute and query executor', async () => {
|
||||
const project = {
|
||||
projectDir: tempDir,
|
||||
configPath: join(tempDir, 'klo.yaml'),
|
||||
config: { project: 'revenue', connections: {} },
|
||||
coreConfig: {},
|
||||
git: {},
|
||||
fileStore: {},
|
||||
} as never;
|
||||
const ports = { knowledge: {}, semanticLayer: {} } as never;
|
||||
const semanticLayerCompute = { query: vi.fn(), validateSources: vi.fn(), generateSources: vi.fn() };
|
||||
const queryExecutor = { execute: vi.fn() };
|
||||
const loadProject = vi.fn(async () => project);
|
||||
const createContextTools = vi.fn(() => ports);
|
||||
|
||||
await expect(
|
||||
createKloAgentRuntime(
|
||||
{ projectDir: tempDir, enableSemanticCompute: true, enableQueryExecution: true },
|
||||
{
|
||||
loadProject,
|
||||
createContextTools,
|
||||
createSemanticLayerCompute: () => semanticLayerCompute,
|
||||
createQueryExecutor: () => queryExecutor,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({ project, ports, queryExecutor });
|
||||
|
||||
expect(loadProject).toHaveBeenCalledWith({ projectDir: tempDir });
|
||||
expect(createContextTools).toHaveBeenCalledWith(project, {
|
||||
semanticLayerCompute,
|
||||
queryExecutor,
|
||||
});
|
||||
});
|
||||
});
|
||||
81
packages/cli/src/agent-runtime.ts
Normal file
81
packages/cli/src/agent-runtime.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { createDefaultLocalQueryExecutor, type KloSqlQueryExecutorPort } from '@klo/context/connections';
|
||||
import { createPythonSemanticLayerComputePort, type KloSemanticLayerComputePort } from '@klo/context/daemon';
|
||||
import { createLocalProjectMcpContextPorts, type KloMcpContextPorts } from '@klo/context/mcp';
|
||||
import { type KloLocalProject, loadKloProject } from '@klo/context/project';
|
||||
import type { KloCliIo } from './cli-runtime.js';
|
||||
|
||||
export const KLO_AGENT_MAX_ROWS_CAP = 1000;
|
||||
|
||||
export interface KloAgentRuntimeOptions {
|
||||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
}
|
||||
|
||||
export interface KloAgentRuntime {
|
||||
project: KloLocalProject;
|
||||
ports: KloMcpContextPorts;
|
||||
semanticLayerCompute?: KloSemanticLayerComputePort;
|
||||
queryExecutor?: KloSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
export interface KloAgentRuntimeDeps {
|
||||
loadProject?: typeof loadKloProject;
|
||||
createContextTools?: typeof createLocalProjectMcpContextPorts;
|
||||
createSemanticLayerCompute?: () => KloSemanticLayerComputePort;
|
||||
createQueryExecutor?: () => KloSqlQueryExecutorPort;
|
||||
}
|
||||
|
||||
export function writeAgentJson(io: KloCliIo, value: unknown): void {
|
||||
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function writeAgentJsonError(
|
||||
io: KloCliIo,
|
||||
message: string,
|
||||
detail: Record<string, unknown> = {},
|
||||
): void {
|
||||
io.stderr.write(`${JSON.stringify({ ok: false, error: { message, ...detail } }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function readAgentJsonFile(path: string): Promise<Record<string, unknown>> {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${path} must contain a JSON object.`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseAgentMaxRows(value: number | undefined): number {
|
||||
if (!Number.isInteger(value) || value === undefined || value <= 0) {
|
||||
throw new Error('maxRows is required and must be a positive integer.');
|
||||
}
|
||||
if (value > KLO_AGENT_MAX_ROWS_CAP) {
|
||||
throw new Error(`maxRows must be less than or equal to ${KLO_AGENT_MAX_ROWS_CAP}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function createKloAgentRuntime(
|
||||
options: KloAgentRuntimeOptions,
|
||||
deps: KloAgentRuntimeDeps = {},
|
||||
): Promise<KloAgentRuntime> {
|
||||
const project = await (deps.loadProject ?? loadKloProject)({ projectDir: options.projectDir });
|
||||
const semanticLayerCompute = options.enableSemanticCompute
|
||||
? (deps.createSemanticLayerCompute ?? createPythonSemanticLayerComputePort)()
|
||||
: undefined;
|
||||
const queryExecutor = options.enableQueryExecution
|
||||
? (deps.createQueryExecutor ?? createDefaultLocalQueryExecutor)()
|
||||
: undefined;
|
||||
const ports = (deps.createContextTools ?? createLocalProjectMcpContextPorts)(project, {
|
||||
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
|
||||
...(queryExecutor ? { queryExecutor } : {}),
|
||||
});
|
||||
return {
|
||||
project,
|
||||
ports,
|
||||
...(semanticLayerCompute ? { semanticLayerCompute } : {}),
|
||||
...(queryExecutor ? { queryExecutor } : {}),
|
||||
};
|
||||
}
|
||||
51
packages/cli/src/agent-search-readiness.test.ts
Normal file
51
packages/cli/src/agent-search-readiness.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isMissingProjectConfigError,
|
||||
missingConnectionSlSearchReadiness,
|
||||
missingProjectSlSearchReadiness,
|
||||
noConnectionsSlSearchReadiness,
|
||||
noIndexedSourcesSlSearchReadiness,
|
||||
} from './agent-search-readiness.js';
|
||||
|
||||
describe('agent semantic-layer search readiness guidance', () => {
|
||||
it('formats missing project guidance with exact recovery commands', () => {
|
||||
expect(missingProjectSlSearchReadiness('/tmp/klo-search', 'gross revenue')).toEqual({
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: 'Semantic-layer search needs an initialized KLO project at /tmp/klo-search.',
|
||||
nextSteps: [
|
||||
'klo demo',
|
||||
'klo setup --project-dir /tmp/klo-search',
|
||||
'klo ingest <connection>',
|
||||
'klo agent sl list --json --query "gross revenue" --project-dir /tmp/klo-search',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('formats no-connection and no-index guidance without hiding the project path', () => {
|
||||
expect(noConnectionsSlSearchReadiness('/tmp/klo-search', 'revenue')).toMatchObject({
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: 'Semantic-layer search found no configured connections in /tmp/klo-search.',
|
||||
});
|
||||
expect(noIndexedSourcesSlSearchReadiness('/tmp/klo-search', 'orders')).toMatchObject({
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: 'Semantic-layer search found no indexed semantic-layer sources in /tmp/klo-search.',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats unknown connection guidance', () => {
|
||||
expect(missingConnectionSlSearchReadiness('/tmp/klo-search', 'warehouse', 'revenue')).toMatchObject({
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: 'Semantic-layer search connection "warehouse" is not configured in /tmp/klo-search.',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects missing klo.yaml read errors', () => {
|
||||
const error = Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
path: '/tmp/klo-search/klo.yaml',
|
||||
});
|
||||
|
||||
expect(isMissingProjectConfigError(error)).toBe(true);
|
||||
expect(isMissingProjectConfigError(new Error('other'))).toBe(false);
|
||||
});
|
||||
});
|
||||
94
packages/cli/src/agent-search-readiness.ts
Normal file
94
packages/cli/src/agent-search-readiness.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
export type KloAgentSlSearchReadinessCode =
|
||||
| 'agent_sl_search_missing_project'
|
||||
| 'agent_sl_search_no_connections'
|
||||
| 'agent_sl_search_unknown_connection'
|
||||
| 'agent_sl_search_no_indexed_sources';
|
||||
|
||||
export interface KloAgentSlSearchReadinessDetail {
|
||||
code: KloAgentSlSearchReadinessCode;
|
||||
message: string;
|
||||
nextSteps: string[];
|
||||
}
|
||||
|
||||
function queryForCommand(query: string | undefined): string {
|
||||
const trimmed = query?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : 'revenue';
|
||||
}
|
||||
|
||||
function projectSearchCommand(projectDir: string, query: string | undefined): string {
|
||||
return `klo agent sl list --json --query ${JSON.stringify(queryForCommand(query))} --project-dir ${projectDir}`;
|
||||
}
|
||||
|
||||
function baseNextSteps(projectDir: string, query: string | undefined): string[] {
|
||||
return [
|
||||
'klo demo',
|
||||
`klo setup --project-dir ${projectDir}`,
|
||||
'klo ingest <connection>',
|
||||
projectSearchCommand(projectDir, query),
|
||||
];
|
||||
}
|
||||
|
||||
export function missingProjectSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KLO project at ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function noConnectionsSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: `Semantic-layer search found no configured connections in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function missingConnectionSlSearchReadiness(
|
||||
projectDir: string,
|
||||
connectionId: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: `Semantic-layer search connection "${connectionId}" is not configured in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
export function noIndexedSourcesSlSearchReadiness(
|
||||
projectDir: string,
|
||||
query: string | undefined,
|
||||
): KloAgentSlSearchReadinessDetail {
|
||||
return {
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: `Semantic-layer search found no indexed semantic-layer sources in ${projectDir}.`,
|
||||
nextSteps: baseNextSteps(projectDir, query),
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
function errorPath(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('path' in error)) {
|
||||
return undefined;
|
||||
}
|
||||
const path = (error as { path?: unknown }).path;
|
||||
return typeof path === 'string' ? path : undefined;
|
||||
}
|
||||
|
||||
export function isMissingProjectConfigError(error: unknown): boolean {
|
||||
return errorCode(error) === 'ENOENT' && (errorPath(error)?.endsWith('klo.yaml') ?? false);
|
||||
}
|
||||
393
packages/cli/src/agent.test.ts
Normal file
393
packages/cli/src/agent.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildDefaultKloProjectConfig } from '@klo/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKloAgent } from './agent.js';
|
||||
import type { KloAgentRuntime } from './agent-runtime.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { write: (chunk: string) => (stdout += chunk) },
|
||||
stderr: { write: (chunk: string) => (stderr += chunk) },
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(overrides: Record<string, unknown> = {}): KloAgentRuntime {
|
||||
const config = buildDefaultKloProjectConfig('revenue');
|
||||
return {
|
||||
project: {
|
||||
projectDir: '/tmp/revenue',
|
||||
configPath: '/tmp/revenue/klo.yaml',
|
||||
config: {
|
||||
...config,
|
||||
connections: {
|
||||
warehouse: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true as const },
|
||||
},
|
||||
},
|
||||
coreConfig: {} as KloAgentRuntime['project']['coreConfig'],
|
||||
git: {} as KloAgentRuntime['project']['git'],
|
||||
fileStore: {} as KloAgentRuntime['project']['fileStore'],
|
||||
},
|
||||
ports: {
|
||||
connections: { list: vi.fn(async () => [{ id: 'warehouse', name: 'warehouse', connectionType: 'sqlite' }]) },
|
||||
semanticLayer: {
|
||||
listSources: vi.fn(async () => ({
|
||||
sources: [
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
connectionName: 'warehouse',
|
||||
name: 'orders',
|
||||
columnCount: 2,
|
||||
measureCount: 1,
|
||||
joinCount: 0,
|
||||
},
|
||||
],
|
||||
totalSources: 1,
|
||||
})),
|
||||
readSource: vi.fn(async () => ({ sourceName: 'orders', yaml: 'name: orders\n' })),
|
||||
writeSource: vi.fn(async () => ({ success: true, sourceName: 'orders' })),
|
||||
validate: vi.fn(async () => ({ success: true, errors: [], warnings: [] })),
|
||||
query: vi.fn(async () => ({ sql: 'select 1', headers: ['x'], rows: [[1]], totalRows: 1, plan: {} })),
|
||||
},
|
||||
knowledge: {
|
||||
search: vi.fn(async () => ({
|
||||
results: [
|
||||
{
|
||||
key: 'page-1',
|
||||
path: 'knowledge/global/page-1.md',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue logic',
|
||||
score: 0.9,
|
||||
matchReasons: ['lexical' as const],
|
||||
},
|
||||
],
|
||||
totalFound: 1,
|
||||
})),
|
||||
read: vi.fn(async () => ({
|
||||
key: 'page-1',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue logic',
|
||||
content: 'Use net revenue.',
|
||||
})),
|
||||
write: vi.fn(async () => ({ success: true, key: 'page-1', action: 'created' as const })),
|
||||
},
|
||||
},
|
||||
queryExecutor: {
|
||||
execute: vi.fn(async () => ({ headers: ['x'], rows: [[1]], totalRows: 1, command: 'SELECT', rowCount: 1 })),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeWithoutConnections(): KloAgentRuntime {
|
||||
const base = runtime();
|
||||
return {
|
||||
...base,
|
||||
project: {
|
||||
...base.project,
|
||||
config: {
|
||||
...base.project.config,
|
||||
connections: {},
|
||||
},
|
||||
},
|
||||
ports: {
|
||||
...base.ports,
|
||||
semanticLayer: {
|
||||
...base.ports.semanticLayer!,
|
||||
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKloAgent', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-agent-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('prints tool discovery with every stable command', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKloAgent({ command: 'tools', projectDir: tempDir, json: true }, io.io)).resolves.toBe(0);
|
||||
|
||||
const body = JSON.parse(io.stdout());
|
||||
expect(body.projectDir).toBe(tempDir);
|
||||
expect(body.tools.map((tool: { name: string }) => tool.name)).toEqual([
|
||||
'context',
|
||||
'sl.list',
|
||||
'sl.read',
|
||||
'sl.query',
|
||||
'wiki.search',
|
||||
'wiki.read',
|
||||
'sql.execute',
|
||||
]);
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints project context from setup status, connections, and SL summaries', async () => {
|
||||
const io = makeIo();
|
||||
const createRuntime = vi.fn(async () => runtime());
|
||||
const readSetupStatus = vi.fn(async () => ({ project: { path: tempDir, ready: true }, agents: [] }));
|
||||
|
||||
await expect(
|
||||
runKloAgent({ command: 'context', projectDir: tempDir, json: true }, io.io, { createRuntime, readSetupStatus }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({
|
||||
projectDir: tempDir,
|
||||
status: { project: { ready: true } },
|
||||
connections: [{ id: 'warehouse' }],
|
||||
semanticLayer: { totalSources: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches SL list, SL read, wiki search, and wiki read through local ports', async () => {
|
||||
for (const args of [
|
||||
{ command: 'sl-list' as const, projectDir: tempDir, json: true as const, connectionId: 'warehouse' },
|
||||
{
|
||||
command: 'sl-read' as const,
|
||||
projectDir: tempDir,
|
||||
json: true as const,
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
},
|
||||
{ command: 'wiki-search' as const, projectDir: tempDir, json: true as const, query: 'revenue', limit: 10 },
|
||||
{ command: 'wiki-read' as const, projectDir: tempDir, json: true as const, pageId: 'page-1' },
|
||||
]) {
|
||||
const io = makeIo();
|
||||
await expect(runKloAgent(args, io.io, { createRuntime: async () => runtime() })).resolves.toBe(0);
|
||||
expect(JSON.parse(io.stdout())).toBeTruthy();
|
||||
expect(io.stderr()).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('prints wiki hybrid search metadata from the hidden agent wiki search command', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const knowledge = fakeRuntime.ports.knowledge;
|
||||
if (!knowledge) {
|
||||
throw new Error('Expected runtime knowledge port');
|
||||
}
|
||||
fakeRuntime.ports.knowledge = {
|
||||
...knowledge,
|
||||
search: vi.fn(async () => ({
|
||||
results: [
|
||||
{
|
||||
key: 'metrics/revenue',
|
||||
path: 'knowledge/global/metrics/revenue.md',
|
||||
scope: 'GLOBAL' as const,
|
||||
summary: 'Revenue metric definition',
|
||||
score: 0.02459016393442623,
|
||||
matchReasons: ['lexical' as const, 'token' as const],
|
||||
},
|
||||
],
|
||||
totalFound: 1,
|
||||
})),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent({ command: 'wiki-search', projectDir: tempDir, json: true, query: 'paid order', limit: 5 }, io.io, {
|
||||
createRuntime: async () => fakeRuntime,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toEqual({
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
key: 'metrics/revenue',
|
||||
path: 'knowledge/global/metrics/revenue.md',
|
||||
matchReasons: ['lexical', 'token'],
|
||||
}),
|
||||
],
|
||||
totalFound: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('executes SL queries from a JSON query file', async () => {
|
||||
const queryFile = join(tempDir, 'sl-query.json');
|
||||
const io = makeIo();
|
||||
await writeFile(queryFile, '{"measures":["total_revenue"],"dimensions":[]}', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{
|
||||
command: 'sl-query',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
queryFile,
|
||||
execute: true,
|
||||
maxRows: 100,
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime: async () => runtime() },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(JSON.parse(io.stdout())).toMatchObject({ sql: 'select 1', rows: [[1]] });
|
||||
});
|
||||
|
||||
it('executes read-only SQL from a SQL file with an explicit row limit', async () => {
|
||||
const sqlFile = join(tempDir, 'query.sql');
|
||||
const fakeRuntime = runtime();
|
||||
const io = makeIo();
|
||||
await writeFile(sqlFile, 'select 1', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{
|
||||
command: 'sql-execute',
|
||||
projectDir: tempDir,
|
||||
json: true,
|
||||
connectionId: 'warehouse',
|
||||
sqlFile,
|
||||
maxRows: 100,
|
||||
},
|
||||
io.io,
|
||||
{ createRuntime: async () => fakeRuntime as never },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(fakeRuntime.queryExecutor?.execute).toHaveBeenCalledWith({
|
||||
connectionId: 'warehouse',
|
||||
projectDir: '/tmp/revenue',
|
||||
connection: { driver: 'sqlite', path: 'warehouse.sqlite', readonly: true },
|
||||
sql: 'select 1',
|
||||
maxRows: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search runs outside a project', async () => {
|
||||
const io = makeIo();
|
||||
const missingProjectError = Object.assign(new Error('ENOENT: no such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
path: join(tempDir, 'klo.yaml'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'gross revenue' },
|
||||
io.io,
|
||||
{ createRuntime: vi.fn(async () => Promise.reject(missingProjectError)) },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_missing_project',
|
||||
message: `Semantic-layer search needs an initialized KLO project at ${tempDir}.`,
|
||||
nextSteps: [
|
||||
'klo demo',
|
||||
`klo setup --project-dir ${tempDir}`,
|
||||
'klo ingest <connection>',
|
||||
`klo agent sl list --json --query "gross revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(io.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search has no configured connections', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => runtimeWithoutConnections() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_no_connections',
|
||||
message: `Semantic-layer search found no configured connections in ${tempDir}.`,
|
||||
nextSteps: [
|
||||
'klo demo',
|
||||
`klo setup --project-dir ${tempDir}`,
|
||||
'klo ingest <connection>',
|
||||
`klo agent sl list --json --query "revenue" --project-dir ${tempDir}`,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search asks for an unknown connection', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'missing', query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => runtime() },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_unknown_connection',
|
||||
message: `Semantic-layer search connection "missing" is not configured in ${tempDir}.`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prints guided JSON when semantic-layer search has no indexed sources', async () => {
|
||||
const fakeRuntime = runtime();
|
||||
const semanticLayer = fakeRuntime.ports.semanticLayer!;
|
||||
fakeRuntime.ports.semanticLayer = {
|
||||
...semanticLayer,
|
||||
listSources: vi.fn(async () => ({ sources: [], totalSources: 0 })),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent(
|
||||
{ command: 'sl-list', projectDir: tempDir, json: true, connectionId: 'warehouse', query: 'revenue' },
|
||||
io.io,
|
||||
{ createRuntime: async () => fakeRuntime },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent_sl_search_no_indexed_sources',
|
||||
message: `Semantic-layer search found no indexed semantic-layer sources in ${tempDir}.`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns JSON errors when required ports or records are missing', async () => {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloAgent({ command: 'wiki-read', projectDir: tempDir, json: true, pageId: 'missing' }, io.io, {
|
||||
createRuntime: async () =>
|
||||
runtime({
|
||||
ports: { knowledge: { read: vi.fn(async () => null) } },
|
||||
}) as never,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(JSON.parse(io.stderr())).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining('missing') },
|
||||
});
|
||||
});
|
||||
});
|
||||
214
packages/cli/src/agent.ts
Normal file
214
packages/cli/src/agent.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import type { KloCliIo } from './cli-runtime.js';
|
||||
import {
|
||||
createKloAgentRuntime,
|
||||
parseAgentMaxRows,
|
||||
readAgentJsonFile,
|
||||
writeAgentJson,
|
||||
writeAgentJsonError,
|
||||
type KloAgentRuntime,
|
||||
type KloAgentRuntimeDeps,
|
||||
} from './agent-runtime.js';
|
||||
import {
|
||||
isMissingProjectConfigError,
|
||||
missingConnectionSlSearchReadiness,
|
||||
missingProjectSlSearchReadiness,
|
||||
noConnectionsSlSearchReadiness,
|
||||
noIndexedSourcesSlSearchReadiness,
|
||||
type KloAgentSlSearchReadinessDetail,
|
||||
} from './agent-search-readiness.js';
|
||||
import { readKloSetupStatus, type KloSetupStatus } from './setup.js';
|
||||
|
||||
export type KloAgentArgs =
|
||||
| { command: 'tools'; projectDir: string; json: true }
|
||||
| { command: 'context'; projectDir: string; json: true }
|
||||
| { command: 'sl-list'; projectDir: string; json: true; connectionId?: string; query?: string }
|
||||
| { command: 'sl-read'; projectDir: string; json: true; connectionId?: string; sourceName: string }
|
||||
| {
|
||||
command: 'sl-query';
|
||||
projectDir: string;
|
||||
json: true;
|
||||
connectionId: string;
|
||||
queryFile: string;
|
||||
execute: boolean;
|
||||
maxRows?: number;
|
||||
}
|
||||
| { command: 'wiki-search'; projectDir: string; json: true; query: string; limit: number }
|
||||
| { command: 'wiki-read'; projectDir: string; json: true; pageId: string }
|
||||
| { command: 'sql-execute'; projectDir: string; json: true; connectionId: string; sqlFile: string; maxRows?: number };
|
||||
|
||||
export interface KloAgentDeps extends KloAgentRuntimeDeps {
|
||||
createRuntime?: (options: {
|
||||
projectDir: string;
|
||||
enableSemanticCompute: boolean;
|
||||
enableQueryExecution: boolean;
|
||||
}) => Promise<KloAgentRuntime>;
|
||||
readSetupStatus?: (
|
||||
projectDir: string,
|
||||
) => Promise<KloSetupStatus | { project: { path?: string; ready: boolean }; agents: unknown[] }>;
|
||||
}
|
||||
|
||||
const AGENT_TOOLS = [
|
||||
{ name: 'context', command: 'klo agent context --json' },
|
||||
{ name: 'sl.list', command: 'klo agent sl list --json [--connection-id <id>] [--query <text>]' },
|
||||
{ name: 'sl.read', command: 'klo agent sl read <sourceName> --json [--connection-id <id>]' },
|
||||
{
|
||||
name: 'sl.query',
|
||||
command: 'klo agent sl query --json --connection-id <id> --query-file <path> --execute --max-rows 100',
|
||||
},
|
||||
{ name: 'wiki.search', command: 'klo agent wiki search <query> --json [--limit 10]' },
|
||||
{ name: 'wiki.read', command: 'klo agent wiki read <pageId> --json' },
|
||||
{
|
||||
name: 'sql.execute',
|
||||
command: 'klo agent sql execute --json --connection-id <id> --sql-file <path> --max-rows 100',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function writeAgentSlSearchReadinessError(io: KloCliIo, detail: KloAgentSlSearchReadinessDetail): void {
|
||||
writeAgentJsonError(io, detail.message, { code: detail.code, nextSteps: detail.nextSteps });
|
||||
}
|
||||
|
||||
async function runtimeFor(args: KloAgentArgs, deps: KloAgentDeps): Promise<KloAgentRuntime> {
|
||||
const needsSemanticCompute = args.command === 'sl-query';
|
||||
const needsQueryExecution = args.command === 'sql-execute' || (args.command === 'sl-query' && args.execute);
|
||||
return deps.createRuntime
|
||||
? deps.createRuntime({
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
})
|
||||
: createKloAgentRuntime(
|
||||
{
|
||||
projectDir: args.projectDir,
|
||||
enableSemanticCompute: needsSemanticCompute,
|
||||
enableQueryExecution: needsQueryExecution,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
function connectionIdForSource(runtime: KloAgentRuntime, requested: string | undefined): string {
|
||||
if (requested) return requested;
|
||||
const ids = Object.keys(runtime.project.config.connections ?? {});
|
||||
if (ids.length === 1) return ids[0] as string;
|
||||
throw new Error('Use --connection-id when the project has zero or multiple connections.');
|
||||
}
|
||||
|
||||
export async function runKloAgent(args: KloAgentArgs, io: KloCliIo, deps: KloAgentDeps = {}): Promise<number> {
|
||||
try {
|
||||
if (args.command === 'tools') {
|
||||
writeAgentJson(io, { projectDir: args.projectDir, tools: AGENT_TOOLS });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const runtime = await runtimeFor(args, deps);
|
||||
|
||||
if (args.command === 'context') {
|
||||
const [status, connections, semanticLayer] = await Promise.all([
|
||||
(deps.readSetupStatus ?? readKloSetupStatus)(args.projectDir),
|
||||
runtime.ports.connections?.list() ?? [],
|
||||
runtime.ports.semanticLayer?.listSources({}) ?? { sources: [], totalSources: 0 },
|
||||
]);
|
||||
writeAgentJson(io, { projectDir: args.projectDir, status, connections, semanticLayer, tools: AGENT_TOOLS });
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-list') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
if (args.query) {
|
||||
const connectionIds = Object.keys(runtime.project.config.connections ?? {});
|
||||
if (args.connectionId && !runtime.project.config.connections[args.connectionId]) {
|
||||
writeAgentSlSearchReadinessError(
|
||||
io,
|
||||
missingConnectionSlSearchReadiness(args.projectDir, args.connectionId, args.query),
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (connectionIds.length === 0) {
|
||||
writeAgentSlSearchReadinessError(io, noConnectionsSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const listed = await semanticLayer.listSources({ connectionId: args.connectionId, query: args.query });
|
||||
if (args.query && listed.sources.length === 0) {
|
||||
const allSources = await semanticLayer.listSources({ connectionId: args.connectionId });
|
||||
if (allSources.totalSources === 0) {
|
||||
writeAgentSlSearchReadinessError(io, noIndexedSourcesSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
writeAgentJson(io, listed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-read') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
const source = await semanticLayer.readSource({
|
||||
connectionId: connectionIdForSource(runtime, args.connectionId),
|
||||
sourceName: args.sourceName,
|
||||
});
|
||||
if (!source) throw new Error(`Semantic-layer source "${args.sourceName}" was not found.`);
|
||||
writeAgentJson(io, source);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sl-query') {
|
||||
const semanticLayer = runtime.ports.semanticLayer;
|
||||
if (!semanticLayer) throw new Error('Semantic-layer tools are not available for this project.');
|
||||
const query = await readAgentJsonFile(args.queryFile);
|
||||
const maxRows = args.execute ? parseAgentMaxRows(args.maxRows) : args.maxRows;
|
||||
writeAgentJson(
|
||||
io,
|
||||
await semanticLayer.query({
|
||||
connectionId: args.connectionId,
|
||||
query: { ...query, ...(maxRows !== undefined ? { limit: maxRows } : {}) } as never,
|
||||
}),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'wiki-search') {
|
||||
const knowledge = runtime.ports.knowledge;
|
||||
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
|
||||
writeAgentJson(io, await knowledge.search({ userId: 'agent', query: args.query, limit: args.limit }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'wiki-read') {
|
||||
const knowledge = runtime.ports.knowledge;
|
||||
if (!knowledge) throw new Error('Wiki tools are not available for this project.');
|
||||
const page = await knowledge.read({ userId: 'agent', key: args.pageId });
|
||||
if (!page) throw new Error(`Wiki page "${args.pageId}" was not found.`);
|
||||
writeAgentJson(io, page);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const queryExecutor = runtime.queryExecutor;
|
||||
if (!queryExecutor) throw new Error('SQL execution is not available for this project.');
|
||||
const connection = runtime.project.config.connections[args.connectionId];
|
||||
if (!connection) throw new Error(`Connection "${args.connectionId}" was not found.`);
|
||||
const maxRows = parseAgentMaxRows(args.maxRows);
|
||||
writeAgentJson(
|
||||
io,
|
||||
await queryExecutor.execute({
|
||||
connectionId: args.connectionId,
|
||||
projectDir: runtime.project.projectDir,
|
||||
connection,
|
||||
sql: await readFile(args.sqlFile, 'utf-8'),
|
||||
maxRows,
|
||||
}),
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (args.command === 'sl-list' && args.query && isMissingProjectConfigError(error)) {
|
||||
writeAgentSlSearchReadinessError(io, missingProjectSlSearchReadiness(args.projectDir, args.query));
|
||||
return 1;
|
||||
}
|
||||
writeAgentJsonError(io, error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
9
packages/cli/src/bin.ts
Normal file
9
packages/cli/src/bin.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { installStartupProfileReporter, profileMark, profileSpan } from './startup-profile.js';
|
||||
|
||||
installStartupProfileReporter();
|
||||
profileMark('bin:entry');
|
||||
const { runKloCli } = await profileSpan('import ./cli-runtime.js', () => import('./cli-runtime.js'));
|
||||
profileMark('bin:runKloCli');
|
||||
process.exitCode = await runKloCli(process.argv.slice(2));
|
||||
11
packages/cli/src/clack.ts
Normal file
11
packages/cli/src/clack.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { spinner } from '@clack/prompts';
|
||||
|
||||
export interface KloCliSpinner {
|
||||
start(message: string): void;
|
||||
stop(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export function createClackSpinner(): KloCliSpinner {
|
||||
return spinner();
|
||||
}
|
||||
268
packages/cli/src/cli-program.ts
Normal file
268
packages/cli/src/cli-program.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import type { KloCliDeps, KloCliIo, KloCliPackageInfo } from './cli-runtime.js';
|
||||
import { registerAgentCommands } from './commands/agent-commands.js';
|
||||
import { registerConnectionCommands } from './commands/connection-commands.js';
|
||||
import { registerWikiCommands } from './commands/knowledge-commands.js';
|
||||
import { registerPublicIngestCommands } from './commands/public-ingest-commands.js';
|
||||
import { registerServeCommands } from './commands/serve-commands.js';
|
||||
import { registerSetupCommands } from './commands/setup-commands.js';
|
||||
import { registerSlCommands } from './commands/sl-commands.js';
|
||||
import { registerStatusCommands } from './commands/status-commands.js';
|
||||
import { registerDevCommands } from './dev.js';
|
||||
import { findNearestKloProjectDir, resolveKloProjectDir } from './project-resolver.js';
|
||||
import { profileMark, profileSpan } from './startup-profile.js';
|
||||
|
||||
profileMark('module:cli-program');
|
||||
|
||||
export interface KloCliCommandContext {
|
||||
io: KloCliIo;
|
||||
deps: KloCliDeps;
|
||||
setExitCode: (code: number) => void;
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KloCliIo) => Promise<number>;
|
||||
writeDebug?: (command: string, commandContext: CommandWithGlobalOptions) => void;
|
||||
}
|
||||
|
||||
export interface OutputModeOptions {
|
||||
plain?: boolean;
|
||||
json?: boolean;
|
||||
viz?: boolean;
|
||||
input?: boolean;
|
||||
}
|
||||
|
||||
interface KloCommanderProgramOptions {
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KloCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
type CommanderExitLike = { exitCode: number; code: string; message: string };
|
||||
|
||||
interface KloGlobalOptionValues {
|
||||
projectDir?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
optsWithGlobals?: () => object;
|
||||
}
|
||||
|
||||
function isCommanderExit(error: unknown): error is CommanderExitLike {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'exitCode' in error &&
|
||||
typeof (error as { exitCode: unknown }).exitCode === 'number' &&
|
||||
'code' in error &&
|
||||
typeof (error as { code: unknown }).code === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function collectOption(value: string, previous: string[] = []): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
export function parsePositiveIntegerOption(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new InvalidArgumentError('must be a positive integer');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseNonNegativeIntegerOption(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new InvalidArgumentError('must be a non-negative integer');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBooleanStringOption(value: string): boolean {
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
throw new InvalidArgumentError('must be true or false');
|
||||
}
|
||||
|
||||
export function parseSafeConnectionIdOption(value: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
|
||||
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseNonEmptyAssignmentOption(value: string): { key: string; value: string } {
|
||||
const separatorIndex = value.indexOf('=');
|
||||
if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
|
||||
throw new InvalidArgumentError('must be a non-empty <key>=<value> assignment');
|
||||
}
|
||||
return {
|
||||
key: value.slice(0, separatorIndex),
|
||||
value: value.slice(separatorIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function optionsWithGlobals(command: CommandWithGlobalOptions): KloGlobalOptionValues {
|
||||
const options = command.optsWithGlobals ? command.optsWithGlobals() : command.opts();
|
||||
const values = options as { projectDir?: unknown; debug?: unknown };
|
||||
return {
|
||||
projectDir: typeof values.projectDir === 'string' ? values.projectDir : undefined,
|
||||
debug: typeof values.debug === 'boolean' ? values.debug : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCommandProjectDir(command: CommandWithGlobalOptions): string {
|
||||
return resolveKloProjectDir({ explicitProjectDir: optionsWithGlobals(command).projectDir });
|
||||
}
|
||||
|
||||
export function resolveCommandProjectDirOverride(command: CommandWithGlobalOptions): string | undefined {
|
||||
return optionsWithGlobals(command).projectDir ?? process.env.KLO_PROJECT_DIR;
|
||||
}
|
||||
|
||||
function createBaseProgram(info: KloCliPackageInfo, io: KloCliIo): Command {
|
||||
return new Command()
|
||||
.name('klo')
|
||||
.description('Standalone KLO developer CLI')
|
||||
.option('--project-dir <path>', 'KLO project directory (default: KLO_PROJECT_DIR, nearest klo.yaml, or cwd)')
|
||||
.option('--debug', 'Enable diagnostic logging to stderr')
|
||||
.version(`${info.name} ${info.version}`, '-v, --version', 'Show CLI version')
|
||||
.helpOption('-h, --help', 'Show this help text')
|
||||
.configureHelp({ showGlobalOptions: true })
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nAdvanced:\n klo dev Low-level diagnostics, scans, adapter commands, and mapping tools.\n',
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
writeOut: (chunk) => io.stdout.write(chunk),
|
||||
writeErr: (chunk) => io.stderr.write(chunk),
|
||||
outputError: (chunk, write) => write(chunk),
|
||||
});
|
||||
}
|
||||
|
||||
function writeDebug(io: KloCliIo, commandContext: CommandWithGlobalOptions, command: string): void {
|
||||
const global = optionsWithGlobals(commandContext);
|
||||
if (global.debug !== true) {
|
||||
return;
|
||||
}
|
||||
io.stderr.write(`[debug] projectDir=${resolveCommandProjectDir(commandContext)}\n`);
|
||||
io.stderr.write(`[debug] dispatch=${command}\n`);
|
||||
}
|
||||
|
||||
function formatCliError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function runBareInteractiveCommand(
|
||||
program: Command,
|
||||
io: KloCliIo,
|
||||
context: KloCliCommandContext,
|
||||
): Promise<number> {
|
||||
const nearestProjectDir = findNearestKloProjectDir(process.cwd());
|
||||
const envProjectDir = process.env.KLO_PROJECT_DIR;
|
||||
const runner = context.deps.setup ?? (await import('./setup.js')).runKloSetup;
|
||||
|
||||
if (!nearestProjectDir && !envProjectDir) {
|
||||
return await runner(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: resolveKloProjectDir(),
|
||||
mode: 'auto',
|
||||
agents: false,
|
||||
agentScope: 'project',
|
||||
agentInstallMode: 'cli',
|
||||
skipAgents: false,
|
||||
inputMode: 'auto',
|
||||
yes: false,
|
||||
skipLlm: false,
|
||||
skipEmbeddings: false,
|
||||
databaseSchemas: [],
|
||||
skipDatabases: false,
|
||||
skipSources: false,
|
||||
},
|
||||
io,
|
||||
);
|
||||
}
|
||||
|
||||
program.outputHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runCommanderKloCli(
|
||||
argv: string[],
|
||||
io: KloCliIo,
|
||||
deps: KloCliDeps,
|
||||
info: KloCliPackageInfo,
|
||||
options: KloCommanderProgramOptions,
|
||||
): Promise<number> {
|
||||
profileMark('commander:entry');
|
||||
let exitCode = 0;
|
||||
const program = createBaseProgram(info, io);
|
||||
profileMark('commander:base-program');
|
||||
const context: KloCliCommandContext = {
|
||||
io,
|
||||
deps,
|
||||
setExitCode: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
runInit: options.runInit,
|
||||
writeDebug: (command: string, commandContext: CommandWithGlobalOptions) => {
|
||||
writeDebug(io, commandContext, command);
|
||||
},
|
||||
};
|
||||
|
||||
registerSetupCommands(program, context);
|
||||
profileMark('commander:register-setup');
|
||||
|
||||
registerConnectionCommands(program, context);
|
||||
profileMark('commander:register-connection');
|
||||
|
||||
registerPublicIngestCommands(program, context);
|
||||
profileMark('commander:register-public-ingest');
|
||||
|
||||
registerWikiCommands(program, context);
|
||||
profileMark('commander:register-wiki');
|
||||
|
||||
registerSlCommands(program, context);
|
||||
profileMark('commander:register-sl');
|
||||
|
||||
registerServeCommands(program, context);
|
||||
profileMark('commander:register-serve');
|
||||
|
||||
registerStatusCommands(program, context);
|
||||
profileMark('commander:register-status');
|
||||
|
||||
registerAgentCommands(program, context);
|
||||
profileMark('commander:register-agent');
|
||||
|
||||
registerDevCommands(program, context);
|
||||
profileMark('commander:register-dev');
|
||||
|
||||
if (argv.length === 0) {
|
||||
if (io.stdout.isTTY === true) {
|
||||
try {
|
||||
return await runBareInteractiveCommand(program, io, context);
|
||||
} catch (error) {
|
||||
io.stderr.write(`${formatCliError(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
program.outputHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await profileSpan('commander:parseAsync', () => program.parseAsync(argv, { from: 'user' }));
|
||||
} catch (error) {
|
||||
if (isCommanderExit(error)) {
|
||||
return error.exitCode === 0 ? 0 : 1;
|
||||
}
|
||||
io.stderr.write(`${formatCliError(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
89
packages/cli/src/cli-runtime.ts
Normal file
89
packages/cli/src/cli-runtime.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { KloConnectionMetabaseSetupArgs } from './commands/connection-metabase-setup.js';
|
||||
import type { KloConnectionNotionArgs } from './commands/connection-notion.js';
|
||||
import type { KloAgentArgs } from './agent.js';
|
||||
import type { KloConnectionArgs } from './connection.js';
|
||||
import type { KloDemoArgs } from './demo.js';
|
||||
import type { KloDoctorArgs } from './doctor.js';
|
||||
import type { KloIngestArgs } from './ingest.js';
|
||||
import type { KloKnowledgeArgs } from './knowledge.js';
|
||||
import type { KloPublicIngestArgs } from './public-ingest.js';
|
||||
import type { KloScanArgs } from './scan.js';
|
||||
import type { KloServeArgs } from './serve.js';
|
||||
import type { KloSetupArgs } from './setup.js';
|
||||
import type { KloSlArgs } from './sl.js';
|
||||
import { profileMark, profileSpan } from './startup-profile.js';
|
||||
|
||||
profileMark('module:cli-runtime');
|
||||
|
||||
export interface KloCliPackageInfo {
|
||||
name: '@klo/cli';
|
||||
version: '0.0.0-private';
|
||||
contextPackageName: '@klo/context';
|
||||
}
|
||||
|
||||
export interface KloCliIo {
|
||||
stdout: { isTTY?: boolean; write(chunk: string): void };
|
||||
stderr: { write(chunk: string): void };
|
||||
}
|
||||
|
||||
export interface KloCliDeps {
|
||||
serveStdio?: (args: KloServeArgs) => Promise<number>;
|
||||
setup?: (args: KloSetupArgs, io: KloCliIo) => Promise<number>;
|
||||
agent?: (args: KloAgentArgs, io: KloCliIo) => Promise<number>;
|
||||
connection?: (args: KloConnectionArgs, io: KloCliIo) => Promise<number>;
|
||||
connectionNotion?: (args: KloConnectionNotionArgs, io: KloCliIo) => Promise<number>;
|
||||
connectionMetabaseSetup?: (args: KloConnectionMetabaseSetupArgs, io: KloCliIo) => Promise<number>;
|
||||
demo?: (args: KloDemoArgs, io: KloCliIo) => Promise<number>;
|
||||
doctor?: (args: KloDoctorArgs, io: KloCliIo) => Promise<number>;
|
||||
ingest?: (args: KloIngestArgs, io: KloCliIo) => Promise<number>;
|
||||
publicIngest?: (args: KloPublicIngestArgs, io: KloCliIo) => Promise<number>;
|
||||
scan?: (args: KloScanArgs, io: KloCliIo) => Promise<number>;
|
||||
knowledge?: (args: KloKnowledgeArgs, io: KloCliIo) => Promise<number>;
|
||||
sl?: (args: KloSlArgs, io: KloCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
export function getKloCliPackageInfo(): KloCliPackageInfo {
|
||||
return {
|
||||
name: '@klo/cli',
|
||||
version: '0.0.0-private',
|
||||
contextPackageName: '@klo/context',
|
||||
};
|
||||
}
|
||||
|
||||
async function runInit(
|
||||
args: { projectDir: string; projectName?: string; force: boolean },
|
||||
io: KloCliIo,
|
||||
): Promise<number> {
|
||||
const { initKloProject } = await import('@klo/context/project');
|
||||
const result = await initKloProject({
|
||||
projectDir: args.projectDir,
|
||||
projectName: args.projectName,
|
||||
force: args.force,
|
||||
});
|
||||
|
||||
io.stdout.write(`Initialized KLO project at ${result.projectDir}\n`);
|
||||
io.stdout.write(`Config: ${result.configPath}\n`);
|
||||
io.stdout.write(`Commit: ${result.commitHash ?? 'none'}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runInitForCommander(
|
||||
args: { projectDir: string; projectName?: string; force: boolean },
|
||||
io: KloCliIo,
|
||||
): Promise<number> {
|
||||
return await runInit(args, io);
|
||||
}
|
||||
|
||||
export async function runKloCli(
|
||||
argv = process.argv.slice(2),
|
||||
io: KloCliIo = process,
|
||||
deps: KloCliDeps = {},
|
||||
): Promise<number> {
|
||||
const info = getKloCliPackageInfo();
|
||||
profileMark('runtime:runKloCli');
|
||||
const { runCommanderKloCli } = await profileSpan('import ./cli-program.js', () => import('./cli-program.js'));
|
||||
|
||||
return await runCommanderKloCli(argv, io, deps, info, {
|
||||
runInit: runInitForCommander,
|
||||
});
|
||||
}
|
||||
85
packages/cli/src/command-schemas.ts
Normal file
85
packages/cli/src/command-schemas.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const projectDirSchema = z.string().min(1);
|
||||
const safeConnectionIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, 'Unsafe connection id');
|
||||
const stringArraySchema = z.array(z.string());
|
||||
|
||||
export const connectionAddCommandSchema = z.object({
|
||||
command: z.literal('add'),
|
||||
projectDir: projectDirSchema,
|
||||
driver: z.string().min(1),
|
||||
connectionId: safeConnectionIdSchema,
|
||||
url: z.string().optional(),
|
||||
schemas: stringArraySchema,
|
||||
readonly: z.boolean(),
|
||||
force: z.boolean(),
|
||||
allowLiteralCredentials: z.boolean(),
|
||||
notion: z
|
||||
.object({
|
||||
authTokenRef: z.string().min(1),
|
||||
crawlMode: z.enum(['all_accessible', 'selected_roots']),
|
||||
rootPageIds: stringArraySchema,
|
||||
rootDatabaseIds: stringArraySchema,
|
||||
rootDataSourceIds: stringArraySchema,
|
||||
maxPagesPerRun: z.number().int().positive().optional(),
|
||||
maxKnowledgeCreatesPerRun: z.number().int().nonnegative().optional(),
|
||||
maxKnowledgeUpdatesPerRun: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const wikiWriteCommandSchema = z.object({
|
||||
command: z.literal('write'),
|
||||
projectDir: projectDirSchema,
|
||||
key: z.string().min(1),
|
||||
scope: z.enum(['GLOBAL', 'USER']),
|
||||
userId: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
tags: stringArraySchema,
|
||||
refs: stringArraySchema,
|
||||
slRefs: stringArraySchema,
|
||||
});
|
||||
|
||||
const orderBySchema = z.union([
|
||||
z.string().min(1),
|
||||
z.object({
|
||||
field: z.string().min(1),
|
||||
direction: z.enum(['asc', 'desc']).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const slQueryCommandSchema = z.object({
|
||||
command: z.literal('query'),
|
||||
projectDir: projectDirSchema,
|
||||
connectionId: z.string().min(1).optional(),
|
||||
query: z.object({
|
||||
measures: z.array(z.string().min(1)).min(1),
|
||||
dimensions: stringArraySchema,
|
||||
filters: stringArraySchema.optional(),
|
||||
segments: stringArraySchema.optional(),
|
||||
order_by: z.array(orderBySchema).optional(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
include_empty: z.literal(true).optional(),
|
||||
}),
|
||||
format: z.enum(['json', 'sql']),
|
||||
execute: z.boolean(),
|
||||
maxRows: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const publicIngestRunCommandSchema = z.object({
|
||||
command: z.literal('run'),
|
||||
projectDir: projectDirSchema,
|
||||
targetConnectionId: safeConnectionIdSchema.optional(),
|
||||
all: z.boolean(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
|
||||
export const publicIngestReadCommandSchema = z.object({
|
||||
command: z.enum(['status', 'watch']),
|
||||
projectDir: projectDirSchema,
|
||||
runId: z.string().min(1).optional(),
|
||||
json: z.boolean(),
|
||||
inputMode: z.enum(['auto', 'disabled']),
|
||||
});
|
||||
137
packages/cli/src/commands/agent-commands.ts
Normal file
137
packages/cli/src/commands/agent-commands.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Option, type Command } from '@commander-js/extra-typings';
|
||||
import type { KloAgentArgs } from '../agent.js';
|
||||
import type { KloCliCommandContext } from '../cli-program.js';
|
||||
import { parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
|
||||
async function runAgent(context: KloCliCommandContext, args: KloAgentArgs): Promise<void> {
|
||||
const runner = context.deps.agent ?? (await import('../agent.js')).runKloAgent;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
function jsonOption(): Option {
|
||||
return new Option('--json', 'Print JSON output').makeOptionMandatory();
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command, context: KloCliCommandContext): void {
|
||||
const agent = program
|
||||
.command('agent', { hidden: true })
|
||||
.description('Machine-readable KLO commands for coding agents')
|
||||
.showHelpAfterError();
|
||||
|
||||
agent.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('agent', actionCommand);
|
||||
});
|
||||
|
||||
agent
|
||||
.command('tools')
|
||||
.description('Print available agent-facing KLO tools')
|
||||
.addOption(jsonOption())
|
||||
.action(async (_options, command) => {
|
||||
await runAgent(context, { command: 'tools', projectDir: resolveCommandProjectDir(command), json: true });
|
||||
});
|
||||
|
||||
agent
|
||||
.command('context')
|
||||
.description('Print project context for agent planning')
|
||||
.addOption(jsonOption())
|
||||
.action(async (_options, command) => {
|
||||
await runAgent(context, { command: 'context', projectDir: resolveCommandProjectDir(command), json: true });
|
||||
});
|
||||
|
||||
const sl = agent.command('sl').description('Semantic-layer agent commands');
|
||||
sl.command('list')
|
||||
.description('List semantic-layer sources')
|
||||
.addOption(jsonOption())
|
||||
.option('--connection-id <id>', 'Filter by connection id')
|
||||
.option('--query <text>', 'Search source names and descriptions')
|
||||
.action(async (options: { connectionId?: string; query?: string }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
...(options.query ? { query: options.query } : {}),
|
||||
});
|
||||
});
|
||||
sl.command('read')
|
||||
.description('Read one semantic-layer source')
|
||||
.argument('<sourceName>')
|
||||
.addOption(jsonOption())
|
||||
.option('--connection-id <id>', 'Connection id containing the source')
|
||||
.action(async (sourceName: string, options: { connectionId?: string }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
sourceName,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
});
|
||||
});
|
||||
sl.command('query')
|
||||
.description('Run a semantic-layer query JSON file')
|
||||
.addOption(jsonOption())
|
||||
.requiredOption('--connection-id <id>', 'Connection id for execution')
|
||||
.requiredOption('--query-file <path>', 'JSON semantic-layer query file')
|
||||
.option('--execute', 'Execute the compiled query against the connection', false)
|
||||
.option('--max-rows <number>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||
.action(
|
||||
async (
|
||||
options: { connectionId: string; queryFile: string; execute: boolean; maxRows?: number },
|
||||
command,
|
||||
) => {
|
||||
await runAgent(context, {
|
||||
command: 'sl-query',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
connectionId: options.connectionId,
|
||||
queryFile: options.queryFile,
|
||||
execute: options.execute,
|
||||
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const wiki = agent.command('wiki').description('KLO wiki agent commands');
|
||||
wiki
|
||||
.command('search')
|
||||
.description('Search KLO wiki pages')
|
||||
.argument('<query>')
|
||||
.addOption(jsonOption())
|
||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption, 10)
|
||||
.action(async (query: string, options: { limit: number }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'wiki-search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
query,
|
||||
limit: options.limit,
|
||||
});
|
||||
});
|
||||
wiki
|
||||
.command('read')
|
||||
.description('Read one KLO wiki page')
|
||||
.argument('<pageId>')
|
||||
.addOption(jsonOption())
|
||||
.action(async (pageId: string, _options, command) => {
|
||||
await runAgent(context, { command: 'wiki-read', projectDir: resolveCommandProjectDir(command), json: true, pageId });
|
||||
});
|
||||
|
||||
const sql = agent.command('sql').description('Safe SQL execution commands');
|
||||
sql
|
||||
.command('execute')
|
||||
.description('Execute read-only SQL with a row limit')
|
||||
.addOption(jsonOption())
|
||||
.requiredOption('--connection-id <id>', 'Connection id for execution')
|
||||
.requiredOption('--sql-file <path>', 'SQL file to execute')
|
||||
.requiredOption('--max-rows <number>', 'Maximum rows to return', parsePositiveIntegerOption)
|
||||
.action(async (options: { connectionId: string; sqlFile: string; maxRows: number }, command) => {
|
||||
await runAgent(context, {
|
||||
command: 'sql-execute',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: true,
|
||||
connectionId: options.connectionId,
|
||||
sqlFile: options.sqlFile,
|
||||
maxRows: options.maxRows,
|
||||
});
|
||||
});
|
||||
}
|
||||
47
packages/cli/src/commands/completion-commands.ts
Normal file
47
packages/cli/src/commands/completion-commands.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { CommandUnknownOpts } from '@commander-js/extra-typings';
|
||||
import type { KloCliCommandContext } from '../cli-program.js';
|
||||
import { completeCommanderInput, installZshCompletion, zshCompletionScript } from '../completion.js';
|
||||
|
||||
export function registerCompletionCommands(
|
||||
program: CommandUnknownOpts,
|
||||
context: KloCliCommandContext,
|
||||
completionRoot: CommandUnknownOpts = program,
|
||||
): void {
|
||||
program
|
||||
.command('completion')
|
||||
.description('Generate shell completion scripts')
|
||||
.command('zsh')
|
||||
.description('Generate zsh completion script')
|
||||
.option('--install', 'Install zsh completion into ~/.zfunc and update ~/.zshrc', false)
|
||||
.action(async (options: { install?: boolean }) => {
|
||||
if (options.install === true) {
|
||||
const result = await installZshCompletion();
|
||||
context.io.stdout.write(`Installed zsh completion: ${result.completionPath}\n`);
|
||||
context.io.stdout.write(`Updated zsh config: ${result.zshrcPath}\n`);
|
||||
context.io.stdout.write('Restart your shell or run: source ~/.zshrc\n');
|
||||
context.setExitCode(0);
|
||||
return;
|
||||
}
|
||||
context.io.stdout.write(zshCompletionScript());
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
program
|
||||
.command('__complete', { hidden: true })
|
||||
.description('Internal shell completion endpoint')
|
||||
.requiredOption('--shell <shell>', 'Shell requesting completions')
|
||||
.requiredOption('--position <position>', 'Current shell word position', (value) => Number(value))
|
||||
.argument('[words...]', 'Current shell words')
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.action((words: string[], options: { shell: string; position: number }) => {
|
||||
if (options.shell !== 'zsh') {
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
for (const completion of completeCommanderInput(completionRoot, { position: options.position, words })) {
|
||||
context.io.stdout.write(`${completion}\n`);
|
||||
}
|
||||
context.setExitCode(0);
|
||||
});
|
||||
}
|
||||
346
packages/cli/src/commands/connection-commands.ts
Normal file
346
packages/cli/src/commands/connection-commands.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import {
|
||||
collectOption,
|
||||
type KloCliCommandContext,
|
||||
parseBooleanStringOption,
|
||||
parseNonEmptyAssignmentOption,
|
||||
parseNonNegativeIntegerOption,
|
||||
parsePositiveIntegerOption,
|
||||
parseSafeConnectionIdOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import { connectionAddCommandSchema } from '../command-schemas.js';
|
||||
import type { KloConnectionArgs } from '../connection.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
import type { KloConnectionMappingArgs } from './connection-mapping.js';
|
||||
import { registerConnectionMetabaseCommands } from './connection-metabase-commands.js';
|
||||
import { registerConnectionNotionCommands } from './connection-notion-commands.js';
|
||||
|
||||
profileMark('module:commands/connection-commands');
|
||||
|
||||
const CRAWL_MODE_CHOICES = ['all_accessible', 'selected_roots'] as const;
|
||||
const SYNC_MODE_CHOICES = ['ALL', 'ONLY', 'EXCEPT'] as const;
|
||||
|
||||
function parseCsvIds(value: string): number[] {
|
||||
return value
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((item) => parsePositiveIntegerOption(item));
|
||||
}
|
||||
|
||||
function parseCsvStrings(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseMappingFieldOption(value: string): 'databaseMappings' | 'connectionMappings' {
|
||||
if (value === 'databaseMappings' || value === 'connectionMappings') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('must be databaseMappings or connectionMappings');
|
||||
}
|
||||
|
||||
async function runConnectionArgs(context: KloCliCommandContext, args: KloConnectionArgs): Promise<void> {
|
||||
const runner = context.deps.connection ?? (await import('../connection.js')).runKloConnection;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
async function runMappingArgs(context: KloCliCommandContext, args: KloConnectionMappingArgs): Promise<void> {
|
||||
const { runKloConnectionMapping } = await import('./connection-mapping.js');
|
||||
context.setExitCode(await runKloConnectionMapping(args, context.io));
|
||||
}
|
||||
|
||||
export function registerConnectionCommands(program: Command, context: KloCliCommandContext, commandName = 'connection'): void {
|
||||
const connection = program
|
||||
.command(commandName)
|
||||
.description('Add, list, test, and map data sources')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the nearest klo.yaml or current working directory.\n',
|
||||
);
|
||||
connection.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.(commandName, actionCommand);
|
||||
});
|
||||
|
||||
connection
|
||||
.command('list')
|
||||
.description('List configured connections')
|
||||
.action(async (_options: unknown, command) => {
|
||||
await runConnectionArgs(context, { command: 'list', projectDir: resolveCommandProjectDir(command) });
|
||||
});
|
||||
|
||||
connection
|
||||
.command('test')
|
||||
.description('Test a configured connection')
|
||||
.argument('<connectionId>', 'KLO connection id')
|
||||
.action(async (connectionId: string, _options: unknown, command) => {
|
||||
await runConnectionArgs(context, {
|
||||
command: 'test',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
});
|
||||
});
|
||||
|
||||
connection
|
||||
.command('add')
|
||||
.description('Add or replace a configured connection')
|
||||
.argument('<driver>', 'Connection driver')
|
||||
.argument('<connectionId>', 'KLO connection id')
|
||||
.option('--url <url>', 'Connection URL, env:NAME, or file:/path reference')
|
||||
.option('--schema <schema>', 'Schema to include; repeatable', collectOption, [])
|
||||
.option('--readonly', 'Mark the connection as read-only', false)
|
||||
.option('--force', 'Replace an existing connection', false)
|
||||
.option('--allow-literal-credentials', 'Allow writing a literal credential URL to klo.yaml', false)
|
||||
.addOption(new Option('--token-env <name>', 'Environment variable containing Notion auth token').conflicts('tokenFile'))
|
||||
.addOption(new Option('--token-file <path>', 'File containing Notion auth token').conflicts('tokenEnv'))
|
||||
.addOption(
|
||||
new Option('--crawl-mode <mode>', 'Notion crawl mode: all_accessible or selected_roots')
|
||||
.choices(CRAWL_MODE_CHOICES)
|
||||
.default('selected_roots'),
|
||||
)
|
||||
.option('--root-page-id <id>', 'Root page to crawl; repeatable', collectOption, [])
|
||||
.option('--root-database-id <id>', 'Root database to crawl; repeatable', collectOption, [])
|
||||
.option('--root-data-source-id <id>', 'Root data source to crawl; repeatable', collectOption, [])
|
||||
.option('--max-pages <n>', 'Maximum pages per run', parsePositiveIntegerOption)
|
||||
.option('--max-knowledge-creates <n>', 'Maximum knowledge creates per run', parseNonNegativeIntegerOption)
|
||||
.option('--max-knowledge-updates <n>', 'Maximum knowledge updates per run', parseNonNegativeIntegerOption)
|
||||
.action(async (driver: string, connectionId: string, options, command) => {
|
||||
const notion =
|
||||
driver === 'notion'
|
||||
? {
|
||||
authTokenRef: options.tokenEnv
|
||||
? `env:${options.tokenEnv}`
|
||||
: options.tokenFile
|
||||
? `file:${options.tokenFile}`
|
||||
: '',
|
||||
crawlMode: options.crawlMode,
|
||||
rootPageIds: options.rootPageId,
|
||||
rootDatabaseIds: options.rootDatabaseId,
|
||||
rootDataSourceIds: options.rootDataSourceId,
|
||||
maxPagesPerRun: options.maxPages,
|
||||
maxKnowledgeCreatesPerRun: options.maxKnowledgeCreates,
|
||||
maxKnowledgeUpdatesPerRun: options.maxKnowledgeUpdates,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (driver === 'notion' && !notion?.authTokenRef) {
|
||||
throw new Error('connection add notion requires --token-env NAME or --token-file PATH');
|
||||
}
|
||||
if (
|
||||
driver === 'notion' &&
|
||||
notion?.crawlMode === 'selected_roots' &&
|
||||
notion.rootPageIds.length + notion.rootDatabaseIds.length + notion.rootDataSourceIds.length === 0
|
||||
) {
|
||||
throw new Error('connection add notion selected_roots requires at least one root id');
|
||||
}
|
||||
|
||||
const args = connectionAddCommandSchema.parse({
|
||||
command: 'add',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
driver,
|
||||
connectionId,
|
||||
url: options.url,
|
||||
schemas: options.schema.filter(Boolean),
|
||||
readonly: options.readonly === true,
|
||||
force: options.force === true,
|
||||
allowLiteralCredentials: options.allowLiteralCredentials === true,
|
||||
notion,
|
||||
});
|
||||
|
||||
await runConnectionArgs(context, args);
|
||||
});
|
||||
|
||||
connection
|
||||
.command('remove')
|
||||
.description('Remove a configured connection from klo.yaml')
|
||||
.argument('<connectionId>', 'KLO connection id')
|
||||
.option('--force', 'Remove without prompting', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (connectionId: string, options: { force?: boolean; input?: boolean }, command) => {
|
||||
await runConnectionArgs(context, {
|
||||
command: 'remove',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
force: options.force === true,
|
||||
...(options.input === false ? { inputMode: 'disabled' } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
connection
|
||||
.command('map')
|
||||
.description('Refresh and validate BI-to-warehouse mappings')
|
||||
.argument('<sourceConnectionId>', 'Source BI connection id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (sourceConnectionId: string, options: { json?: boolean }, command) => {
|
||||
await runConnectionArgs(context, {
|
||||
command: 'map',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
sourceConnectionId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
registerConnectionMappingCommands(connection, context);
|
||||
registerConnectionMetabaseCommands(connection, context);
|
||||
registerConnectionNotionCommands(connection, context);
|
||||
}
|
||||
|
||||
export function registerConnectionMappingCommands(connection: Command, context: KloCliCommandContext): void {
|
||||
const mapping = connection
|
||||
.command('mapping')
|
||||
.description('Manage Metabase warehouse mappings')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
mapping
|
||||
.command('list')
|
||||
.description('List Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.option('--json', 'Print JSON output where supported', false)
|
||||
.action(async (connectionId: string, options: { json?: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('set')
|
||||
.description('Set a Metabase or Looker warehouse mapping')
|
||||
.argument('<connectionId>', 'Source connection id', parseSafeConnectionIdOption)
|
||||
.argument('<field>', 'Mapping field', parseMappingFieldOption)
|
||||
.argument('<assignment>', 'Mapping assignment such as 1=prod-warehouse', parseNonEmptyAssignmentOption)
|
||||
.action(
|
||||
async (
|
||||
connectionId: string,
|
||||
field: 'databaseMappings' | 'connectionMappings',
|
||||
assignment: { key: string; value: string },
|
||||
_options: unknown,
|
||||
command,
|
||||
) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'set',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
field,
|
||||
key: assignment.key,
|
||||
value: assignment.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
mapping
|
||||
.command('apply-bulk')
|
||||
.description('Apply mappings from JSON')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.requiredOption('--file <path>', 'JSON mapping file')
|
||||
.action(async (connectionId: string, options: { file: string }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'apply-bulk',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
filePath: options.file,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('set-sync-enabled')
|
||||
.description('Enable or disable sync for one Metabase database')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.argument('<metabaseDatabaseId>', 'Metabase database id', parsePositiveIntegerOption)
|
||||
.requiredOption('--enabled <value>', 'true or false', parseBooleanStringOption)
|
||||
.action(
|
||||
async (connectionId: string, metabaseDatabaseId: number, options: { enabled: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'set-sync-enabled',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
metabaseDatabaseId,
|
||||
enabled: options.enabled,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const syncState = mapping.command('sync-state').description('Manage Metabase sync-state selection');
|
||||
syncState
|
||||
.command('get')
|
||||
.description('Read sync-state selection')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.option('--json', 'Print JSON output where supported', false)
|
||||
.action(async (connectionId: string, options: { json?: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'sync-state-get',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
syncState
|
||||
.command('set')
|
||||
.description('Write sync-state selection')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.addOption(new Option('--mode <mode>', 'ALL, ONLY, or EXCEPT').choices(SYNC_MODE_CHOICES).makeOptionMandatory())
|
||||
.option('--collections <ids>', 'Comma-separated collection ids', parseCsvIds, [])
|
||||
.option('--items <ids>', 'Comma-separated item ids', parseCsvIds, [])
|
||||
.option('--tag-names <names>', 'Comma-separated tag names', parseCsvStrings, [])
|
||||
.action(async (connectionId: string, options, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'sync-state-set',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
syncMode: options.mode,
|
||||
collectionIds: options.collections,
|
||||
itemIds: options.items,
|
||||
tagNames: options.tagNames,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('refresh')
|
||||
.description('Refresh Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.option('--auto-accept', 'Accept refresh changes without prompting', false)
|
||||
.action(async (connectionId: string, options: { autoAccept?: boolean }, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'refresh',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
autoAccept: options.autoAccept === true,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('validate')
|
||||
.description('Validate Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.action(async (connectionId: string, _options: unknown, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'validate',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
});
|
||||
});
|
||||
|
||||
mapping
|
||||
.command('clear')
|
||||
.description('Clear Metabase database mappings')
|
||||
.argument('<connectionId>', 'Metabase connection id')
|
||||
.argument('[metabaseDatabaseId]', 'Metabase database id', parsePositiveIntegerOption)
|
||||
.action(async (connectionId: string, metabaseDatabaseId: number | undefined, _options: unknown, command) => {
|
||||
await runMappingArgs(context, {
|
||||
command: 'clear',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
...(metabaseDatabaseId ? { metabaseDatabaseId } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
329
packages/cli/src/commands/connection-mapping.test.ts
Normal file
329
packages/cli/src/commands/connection-mapping.test.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { LocalMetabaseSourceStateReader } from '@klo/context/ingest';
|
||||
import { initKloProject, loadKloProject, serializeKloProjectConfig } from '@klo/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKloConnectionMapping } from './connection-mapping.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKloConnectionMapping', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-cli-metabase-mapping-'));
|
||||
projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'mapping' });
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections: {
|
||||
'prod-metabase': {
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
api_key_ref: 'env:METABASE_API_KEY', // pragma: allowlist secret
|
||||
},
|
||||
'prod-warehouse': {
|
||||
driver: 'postgres',
|
||||
url: 'env:WAREHOUSE_URL',
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Seed Metabase mapping test connections',
|
||||
);
|
||||
});
|
||||
|
||||
async function replaceConnections(connections: Record<string, { driver: string; [key: string]: unknown }>) {
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections,
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Replace mapping test connections',
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sets, lists, disables, and clears local Metabase mappings', async () => {
|
||||
const io = makeIo();
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
field: 'databaseMappings',
|
||||
key: '1',
|
||||
value: 'prod-warehouse',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(
|
||||
runKloConnectionMapping({ command: 'list', projectDir, connectionId: 'prod-metabase', json: false }, listIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(listIo.stdout()).toContain('1 -> prod-warehouse');
|
||||
expect(listIo.stdout()).toContain('unhydrated');
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set-sync-enabled',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
metabaseDatabaseId: 1,
|
||||
enabled: false,
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'clear',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
metabaseDatabaseId: 1,
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('lists Metabase yaml mapping bootstrap rows before any SQLite command writes', async () => {
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'klo-cli-yaml-mapping-'));
|
||||
await initKloProject({ projectDir, projectName: 'yaml-mapping' });
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections: {
|
||||
'prod-metabase': {
|
||||
driver: 'metabase',
|
||||
mappings: {
|
||||
databaseMappings: { '1': 'prod-warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
},
|
||||
},
|
||||
'prod-warehouse': { driver: 'postgres', url: 'postgresql://readonly@db.test/analytics' },
|
||||
},
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Seed yaml mappings',
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{ command: 'list', projectDir, connectionId: 'prod-metabase', json: false },
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('1 -> prod-warehouse');
|
||||
expect(io.stdout()).toContain('source: klo.yaml');
|
||||
});
|
||||
|
||||
it('refreshes Metabase discovery metadata through the injected runtime client', async () => {
|
||||
const client = {
|
||||
getDatabases: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Analytics',
|
||||
engine: 'postgres',
|
||||
details: { host: 'pg.internal', dbname: 'analytics' },
|
||||
is_sample: false,
|
||||
},
|
||||
]),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'refresh',
|
||||
projectDir,
|
||||
connectionId: 'prod-metabase',
|
||||
autoAccept: true,
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
createMetabaseClient: async () => client as never,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Discovery: 1 database');
|
||||
expect(client.cleanup).toHaveBeenCalledTimes(1);
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: join(projectDir, '.klo', 'db.sqlite') });
|
||||
await expect(store.listDatabaseMappings('prod-metabase')).resolves.toMatchObject([
|
||||
{ metabaseDatabaseId: 1, metabaseDatabaseName: 'Analytics', source: 'refresh' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets and lists Looker connection mappings', async () => {
|
||||
await replaceConnections({
|
||||
'prod-looker': {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.test',
|
||||
client_id: 'id',
|
||||
},
|
||||
'prod-warehouse': {
|
||||
driver: 'postgres',
|
||||
url: 'postgresql://readonly@db.example.test/analytics',
|
||||
},
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-looker',
|
||||
field: 'connectionMappings',
|
||||
key: 'analytics',
|
||||
value: 'prod-warehouse',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
runKloConnectionMapping({ command: 'list', projectDir, connectionId: 'prod-looker', json: false }, io.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('analytics -> prod-warehouse');
|
||||
});
|
||||
|
||||
it('keeps driver-specific mapping field validation in the runner', async () => {
|
||||
await replaceConnections({
|
||||
'prod-looker': { driver: 'looker', base_url: 'https://looker.example.com' },
|
||||
warehouse: { driver: 'postgres', url: 'env:WAREHOUSE_URL' },
|
||||
});
|
||||
|
||||
const io = makeIo();
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{
|
||||
command: 'set',
|
||||
projectDir,
|
||||
connectionId: 'prod-looker',
|
||||
field: 'databaseMappings',
|
||||
key: '1',
|
||||
value: 'warehouse',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('Looker mapping set requires connectionMappings');
|
||||
});
|
||||
|
||||
it('refreshes Looker mapping metadata and reports drift', async () => {
|
||||
await replaceConnections({
|
||||
'prod-looker': {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.test',
|
||||
client_id: 'id',
|
||||
},
|
||||
'prod-warehouse': {
|
||||
driver: 'postgres',
|
||||
url: 'postgresql://readonly@db.example.test/analytics',
|
||||
},
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping(
|
||||
{ command: 'refresh', projectDir, connectionId: 'prod-looker', autoAccept: true },
|
||||
io.io,
|
||||
{
|
||||
createLookerClient: async () => ({
|
||||
listLookerConnections: async () => [
|
||||
{
|
||||
name: 'analytics',
|
||||
host: 'db.example.test',
|
||||
database: 'analytics',
|
||||
schema: null,
|
||||
dialect: 'postgres',
|
||||
},
|
||||
],
|
||||
cleanup: async () => {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Discovery: 1 connection');
|
||||
expect(io.stdout()).toContain('Unmapped discovered: 1');
|
||||
});
|
||||
|
||||
it('validates Looker mappings through the canonical local warehouse descriptor', async () => {
|
||||
const projectDir = await mkdtemp(join(tmpdir(), 'klo-cli-descriptor-validation-'));
|
||||
await initKloProject({ projectDir, projectName: 'descriptor-validation' });
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig({
|
||||
...project.config,
|
||||
connections: {
|
||||
'prod-looker': {
|
||||
driver: 'looker',
|
||||
mappings: { connectionMappings: { analytics: 'prod-warehouse' } },
|
||||
},
|
||||
'prod-warehouse': { driver: 'postgresql', url: 'postgresql://readonly@db.test/analytics' },
|
||||
},
|
||||
}),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'Seed descriptor validation',
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionMapping({ command: 'validate', projectDir, connectionId: 'prod-looker' }, io.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Mapping validation passed: prod-looker');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
});
|
||||
426
packages/cli/src/commands/connection-mapping.ts
Normal file
426
packages/cli/src/commands/connection-mapping.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { localConnectionToWarehouseDescriptor } from '@klo/context/connections';
|
||||
import {
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
DefaultLookerConnectionClientFactory,
|
||||
DefaultMetabaseConnectionClientFactory,
|
||||
LocalLookerRuntimeStore,
|
||||
LocalMetabaseSourceStateReader,
|
||||
computeLookerMappingDrift,
|
||||
computeMetabaseMappingDrift,
|
||||
discoverLookerConnections,
|
||||
discoverMetabaseDatabases,
|
||||
lookerCredentialsFromLocalConnection,
|
||||
metabaseRuntimeConfigFromLocalConnection,
|
||||
seedLocalMappingStateFromKloYaml,
|
||||
validateLookerMappings,
|
||||
validateMappingPhysicalMatch,
|
||||
type LookerMappingClient,
|
||||
type MetabaseRuntimeClient,
|
||||
type MetabaseSyncMode,
|
||||
} from '@klo/context/ingest';
|
||||
import { type KloLocalProject, kloLocalStateDbPath, loadKloProject } from '@klo/context/project';
|
||||
import type { KloCliIo } from '../index.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/connection-mapping');
|
||||
|
||||
export type KloConnectionMappingArgs =
|
||||
| { command: 'list'; projectDir: string; connectionId: string; json: boolean }
|
||||
| {
|
||||
command: 'set';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
field: 'databaseMappings' | 'connectionMappings';
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
| { command: 'apply-bulk'; projectDir: string; connectionId: string; filePath: string }
|
||||
| {
|
||||
command: 'set-sync-enabled';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
metabaseDatabaseId: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
| { command: 'sync-state-get'; projectDir: string; connectionId: string; json: boolean }
|
||||
| {
|
||||
command: 'sync-state-set';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
syncMode: MetabaseSyncMode;
|
||||
collectionIds: number[];
|
||||
itemIds: number[];
|
||||
tagNames: string[];
|
||||
}
|
||||
| { command: 'refresh'; projectDir: string; connectionId: string; autoAccept: boolean }
|
||||
| { command: 'validate'; projectDir: string; connectionId: string }
|
||||
| { command: 'clear'; projectDir: string; connectionId: string; metabaseDatabaseId?: number; mappingKey?: string };
|
||||
|
||||
interface KloConnectionMappingDeps {
|
||||
createMetabaseClient?: (
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
) => Promise<Pick<MetabaseRuntimeClient, 'getDatabases' | 'cleanup'>>;
|
||||
createLookerClient?: (
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
) => Promise<Pick<LookerMappingClient, 'listLookerConnections'> & { cleanup?(): Promise<void> }>;
|
||||
}
|
||||
|
||||
interface MetabaseBulkMappingPayload {
|
||||
databaseMappings?: Record<string, string | null>;
|
||||
syncEnabled?: Record<string, boolean>;
|
||||
syncMode?: MetabaseSyncMode;
|
||||
selections?: { collections?: number[]; items?: number[] };
|
||||
defaultTagNames?: string[];
|
||||
}
|
||||
|
||||
function parseId(value: string, label: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function createDefaultMetabaseClient(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Pick<MetabaseRuntimeClient, 'getDatabases' | 'cleanup'>> {
|
||||
const factory = new DefaultMetabaseConnectionClientFactory(
|
||||
(metabaseConnectionId) =>
|
||||
metabaseRuntimeConfigFromLocalConnection(metabaseConnectionId, project.config.connections[metabaseConnectionId]),
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
);
|
||||
return factory.createClient(connectionId);
|
||||
}
|
||||
|
||||
async function createDefaultLookerClient(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Pick<LookerMappingClient, 'listLookerConnections'> & { cleanup?(): Promise<void> }> {
|
||||
const factory = new DefaultLookerConnectionClientFactory({
|
||||
async resolve(lookerConnectionId) {
|
||||
return lookerCredentialsFromLocalConnection(lookerConnectionId, project.config.connections[lookerConnectionId]);
|
||||
},
|
||||
});
|
||||
return factory.createClient(connectionId) as unknown as Pick<LookerMappingClient, 'listLookerConnections'> & {
|
||||
cleanup?(): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
function isLookerConnection(project: KloLocalProject, connectionId: string): boolean {
|
||||
return String(project.config.connections[connectionId]?.driver ?? '').toLowerCase() === 'looker';
|
||||
}
|
||||
|
||||
function assertLookerConnection(project: KloLocalProject, connectionId: string): void {
|
||||
if (!isLookerConnection(project, connectionId)) {
|
||||
throw new Error(`Connection "${connectionId}" is not a Looker connection`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMetabaseConnection(project: KloLocalProject, connectionId: string): void {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection || String(connection.driver).toLowerCase() !== 'metabase') {
|
||||
throw new Error(`Connection "${connectionId}" is not a Metabase connection`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTargetConnection(project: KloLocalProject, connectionId: string): void {
|
||||
if (!project.config.connections[connectionId]) {
|
||||
throw new Error(`Target connection "${connectionId}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
function targetPhysicalInfo(project: KloLocalProject, connectionId: string) {
|
||||
const descriptor = localConnectionToWarehouseDescriptor(connectionId, project.config.connections[connectionId]);
|
||||
if (!descriptor) {
|
||||
return { connection_type: 'UNKNOWN' };
|
||||
}
|
||||
return {
|
||||
connection_type: descriptor.connection_type,
|
||||
host: descriptor.host ?? null,
|
||||
database: descriptor.database ?? null,
|
||||
account: descriptor.account ?? null,
|
||||
project_id: descriptor.project_id ?? null,
|
||||
dataset_id: descriptor.dataset_id ?? null,
|
||||
...descriptor.connection_params,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMapping(
|
||||
row: Awaited<ReturnType<LocalMetabaseSourceStateReader['listDatabaseMappings']>>[number],
|
||||
): string {
|
||||
const name = row.metabaseDatabaseName ?? 'unhydrated';
|
||||
const target = row.targetConnectionId ?? '[unmapped]';
|
||||
return `${row.metabaseDatabaseId} -> ${target} (${name}, sync: ${row.syncEnabled ? 'on' : 'off'}, source: ${
|
||||
row.source
|
||||
})`;
|
||||
}
|
||||
|
||||
function renderLookerMapping(row: Awaited<ReturnType<LocalLookerRuntimeStore['listConnectionMappings']>>[number]): string {
|
||||
const target = row.kloConnectionId ?? '[unmapped]';
|
||||
const metadata = [row.lookerDialect, row.lookerHost, row.lookerDatabase].filter(Boolean).join(', ');
|
||||
return `${row.lookerConnectionName} -> ${target}${metadata ? ` (${metadata}, source: ${row.source})` : ` (source: ${row.source})`}`;
|
||||
}
|
||||
|
||||
export async function runKloConnectionMapping(
|
||||
args: KloConnectionMappingArgs,
|
||||
io: KloCliIo = process,
|
||||
deps: KloConnectionMappingDeps = {},
|
||||
): Promise<number> {
|
||||
try {
|
||||
const project = await loadKloProject({ projectDir: args.projectDir });
|
||||
await seedLocalMappingStateFromKloYaml(project, args.connectionId);
|
||||
if (isLookerConnection(project, args.connectionId)) {
|
||||
assertLookerConnection(project, args.connectionId);
|
||||
const store = new LocalLookerRuntimeStore({ dbPath: kloLocalStateDbPath(project) });
|
||||
|
||||
if (args.command === 'list') {
|
||||
const rows = await store.listConnectionMappings(args.connectionId);
|
||||
io.stdout.write(args.json ? `${JSON.stringify(rows, null, 2)}\n` : `${rows.map(renderLookerMapping).join('\n')}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set') {
|
||||
if (args.field !== 'connectionMappings') {
|
||||
throw new Error('Looker mapping set requires connectionMappings <lookerConnectionName>=<targetConnectionId>');
|
||||
}
|
||||
assertTargetConnection(project, args.value);
|
||||
await store.upsertConnectionMapping({
|
||||
lookerConnectionId: args.connectionId,
|
||||
lookerConnectionName: args.key,
|
||||
kloConnectionId: args.value,
|
||||
source: 'cli',
|
||||
});
|
||||
io.stdout.write(`Set connectionMappings.${args.key} = ${args.value}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'refresh') {
|
||||
const client = await (deps.createLookerClient ?? createDefaultLookerClient)(project, args.connectionId);
|
||||
try {
|
||||
const discovered = await discoverLookerConnections(client);
|
||||
const drift = computeLookerMappingDrift({
|
||||
storedMappings: await store.readMappings(args.connectionId),
|
||||
discovered,
|
||||
});
|
||||
if (args.autoAccept) {
|
||||
await store.refreshDiscoveredConnections({ lookerConnectionId: args.connectionId, discovered });
|
||||
}
|
||||
io.stdout.write(`Discovery: ${discovered.length} ${discovered.length === 1 ? 'connection' : 'connections'}\n`);
|
||||
io.stdout.write(`Unmapped discovered: ${drift.unmappedDiscovered.length}\n`);
|
||||
io.stdout.write(`Stale mappings: ${drift.staleMappings.length}\n`);
|
||||
return 0;
|
||||
} finally {
|
||||
await client.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (args.command === 'validate') {
|
||||
const knownKloConnectionIds = new Set(Object.keys(project.config.connections));
|
||||
const knownConnectionTypes = new Map(
|
||||
Object.entries(project.config.connections).map(([id, _config]) => [id, targetPhysicalInfo(project, id).connection_type]),
|
||||
);
|
||||
const validation = validateLookerMappings({
|
||||
mappings: await store.readMappings(args.connectionId),
|
||||
knownKloConnectionIds,
|
||||
knownConnectionTypes,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
for (const error of validation.errors) {
|
||||
io.stderr.write(`${error.key}: ${error.reason}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
io.stdout.write(`Mapping validation passed: ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'clear') {
|
||||
await store.clearConnectionMappings({
|
||||
lookerConnectionId: args.connectionId,
|
||||
lookerConnectionName: args.mappingKey ?? (args.metabaseDatabaseId ? String(args.metabaseDatabaseId) : undefined),
|
||||
});
|
||||
io.stdout.write(
|
||||
args.mappingKey
|
||||
? `Cleared connectionMappings.${args.mappingKey}\n`
|
||||
: `Cleared mappings for ${args.connectionId}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
throw new Error(`Looker connection mapping does not support ${args.command}`);
|
||||
}
|
||||
|
||||
assertMetabaseConnection(project, args.connectionId);
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: kloLocalStateDbPath(project) });
|
||||
|
||||
if (args.command === 'list') {
|
||||
const rows = await store.listDatabaseMappings(args.connectionId);
|
||||
io.stdout.write(args.json ? `${JSON.stringify(rows, null, 2)}\n` : `${rows.map(renderMapping).join('\n')}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set') {
|
||||
assertTargetConnection(project, args.value);
|
||||
await store.upsertDatabaseMapping({
|
||||
connectionId: args.connectionId,
|
||||
metabaseDatabaseId: parseId(args.key, 'metabaseDatabaseId'),
|
||||
targetConnectionId: args.value,
|
||||
syncEnabled: true,
|
||||
source: 'cli',
|
||||
});
|
||||
io.stdout.write(`Set databaseMappings.${args.key} = ${args.value}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'apply-bulk') {
|
||||
const payload = JSON.parse(await readFile(args.filePath, 'utf8')) as MetabaseBulkMappingPayload;
|
||||
const existingState = await store.getSourceState(args.connectionId);
|
||||
const existingRows = await store.listDatabaseMappings(args.connectionId);
|
||||
const existingById = new Map(existingRows.map((row) => [row.metabaseDatabaseId, row]));
|
||||
const databaseMappings = payload.databaseMappings ?? {};
|
||||
for (const targetConnectionId of Object.values(databaseMappings)) {
|
||||
if (targetConnectionId) {
|
||||
assertTargetConnection(project, targetConnectionId);
|
||||
}
|
||||
}
|
||||
const mappingIds = new Set([
|
||||
...existingRows.map((row) => row.metabaseDatabaseId),
|
||||
...Object.keys(databaseMappings).map((id) => parseId(id, 'metabaseDatabaseId')),
|
||||
...Object.keys(payload.syncEnabled ?? {}).map((id) => parseId(id, 'metabaseDatabaseId')),
|
||||
]);
|
||||
await store.replaceSourceState({
|
||||
connectionId: args.connectionId,
|
||||
syncMode: payload.syncMode ?? existingState.syncMode,
|
||||
defaultTagNames: payload.defaultTagNames ?? existingState.defaultTagNames,
|
||||
selections:
|
||||
payload.selections === undefined
|
||||
? existingState.selections
|
||||
: [
|
||||
...(payload.selections.collections ?? []).map((id) => ({
|
||||
selectionType: 'collection' as const,
|
||||
metabaseObjectId: id,
|
||||
})),
|
||||
...(payload.selections.items ?? []).map((id) => ({
|
||||
selectionType: 'item' as const,
|
||||
metabaseObjectId: id,
|
||||
})),
|
||||
],
|
||||
mappings: [...mappingIds]
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => {
|
||||
const existing = existingById.get(id);
|
||||
return {
|
||||
metabaseDatabaseId: id,
|
||||
metabaseDatabaseName: existing?.metabaseDatabaseName ?? null,
|
||||
metabaseEngine: existing?.metabaseEngine ?? null,
|
||||
metabaseHost: existing?.metabaseHost ?? null,
|
||||
metabaseDbName: existing?.metabaseDbName ?? null,
|
||||
targetConnectionId: databaseMappings[String(id)] ?? existing?.targetConnectionId ?? null,
|
||||
syncEnabled: payload.syncEnabled?.[String(id)] ?? existing?.syncEnabled ?? false,
|
||||
source: 'cli',
|
||||
};
|
||||
}),
|
||||
});
|
||||
io.stdout.write(`Applied bulk mappings for ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'set-sync-enabled') {
|
||||
await store.setMappingSyncEnabled({
|
||||
connectionId: args.connectionId,
|
||||
metabaseDatabaseId: args.metabaseDatabaseId,
|
||||
syncEnabled: args.enabled,
|
||||
});
|
||||
io.stdout.write(`Set syncEnabled.${args.metabaseDatabaseId} = ${args.enabled}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sync-state-get') {
|
||||
const state = await store.getSourceState(args.connectionId);
|
||||
const payload = {
|
||||
syncMode: state.syncMode,
|
||||
selections: state.selections,
|
||||
defaultTagNames: state.defaultTagNames,
|
||||
};
|
||||
io.stdout.write(args.json ? `${JSON.stringify(payload, null, 2)}\n` : `${payload.syncMode}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'sync-state-set') {
|
||||
await store.setSyncState({
|
||||
connectionId: args.connectionId,
|
||||
syncMode: args.syncMode,
|
||||
defaultTagNames: args.tagNames,
|
||||
selections: [
|
||||
...args.collectionIds.map((id) => ({ selectionType: 'collection' as const, metabaseObjectId: id })),
|
||||
...args.itemIds.map((id) => ({ selectionType: 'item' as const, metabaseObjectId: id })),
|
||||
],
|
||||
});
|
||||
io.stdout.write(`Set sync state for ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'refresh') {
|
||||
const client = await (deps.createMetabaseClient ?? createDefaultMetabaseClient)(project, args.connectionId);
|
||||
try {
|
||||
const discovered = await discoverMetabaseDatabases(client);
|
||||
const existing = Object.fromEntries(
|
||||
(await store.listDatabaseMappings(args.connectionId)).map((row) => [
|
||||
String(row.metabaseDatabaseId),
|
||||
row.targetConnectionId,
|
||||
]),
|
||||
);
|
||||
const drift = computeMetabaseMappingDrift({ currentMappings: existing, discovered });
|
||||
if (args.autoAccept) {
|
||||
await store.refreshDiscoveredDatabases({ connectionId: args.connectionId, discovered });
|
||||
}
|
||||
io.stdout.write(`Discovery: ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}\n`);
|
||||
io.stdout.write(`Unmapped discovered: ${drift.unmappedDiscovered.length}\n`);
|
||||
io.stdout.write(`Stale mappings: ${drift.staleMappings.length}\n`);
|
||||
return 0;
|
||||
} finally {
|
||||
await client.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (args.command === 'validate') {
|
||||
const rows = await store.listDatabaseMappings(args.connectionId);
|
||||
const failures = rows.flatMap((row) => {
|
||||
if (!row.targetConnectionId) {
|
||||
return [];
|
||||
}
|
||||
const reason = validateMappingPhysicalMatch(
|
||||
{ metabaseEngine: row.metabaseEngine, metabaseDbName: row.metabaseDbName, metabaseHost: row.metabaseHost },
|
||||
project.config.connections[row.targetConnectionId]
|
||||
? targetPhysicalInfo(project, row.targetConnectionId)
|
||||
: { connection_type: 'UNKNOWN' },
|
||||
);
|
||||
return reason ? [`${row.metabaseDatabaseId}: ${reason}`] : [];
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
io.stderr.write(`${failure}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
io.stdout.write(`Mapping validation passed: ${args.connectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const metabaseDatabaseId = args.metabaseDatabaseId ?? (args.mappingKey ? parseId(args.mappingKey, 'metabaseDatabaseId') : undefined);
|
||||
await store.clearDatabaseMappings({ connectionId: args.connectionId, metabaseDatabaseId });
|
||||
io.stdout.write(
|
||||
metabaseDatabaseId
|
||||
? `Cleared databaseMappings.${metabaseDatabaseId}\n`
|
||||
: `Cleared mappings for ${args.connectionId}\n`,
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
132
packages/cli/src/commands/connection-metabase-commands.ts
Normal file
132
packages/cli/src/commands/connection-metabase-commands.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
|
||||
import {
|
||||
type KloCliCommandContext,
|
||||
parseNonEmptyAssignmentOption,
|
||||
parsePositiveIntegerOption,
|
||||
parseSafeConnectionIdOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import {
|
||||
type KloConnectionMetabaseSetupArgs,
|
||||
type MetabaseSetupMappingAssignment,
|
||||
type MetabaseSetupSyncMode,
|
||||
runKloConnectionMetabaseSetup,
|
||||
} from './connection-metabase-setup.js';
|
||||
|
||||
const SYNC_MODE_CHOICES = ['ALL', 'ONLY', 'EXCEPT'] as const satisfies readonly MetabaseSetupSyncMode[];
|
||||
|
||||
interface ConnectionMetabaseSetupOptions {
|
||||
id?: string;
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
mintApiKey?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
map: MetabaseSetupMappingAssignment[];
|
||||
sync: number[];
|
||||
syncMode: MetabaseSetupSyncMode;
|
||||
runIngest?: boolean;
|
||||
yes?: boolean;
|
||||
input?: boolean;
|
||||
}
|
||||
|
||||
function collectPositiveIntegerOption(value: string, previous: number[] = []): number[] {
|
||||
return [...previous, parsePositiveIntegerOption(value)];
|
||||
}
|
||||
|
||||
function parseMappingAssignment(value: string): MetabaseSetupMappingAssignment {
|
||||
const assignment = parseNonEmptyAssignmentOption(value);
|
||||
return {
|
||||
metabaseDatabaseId: parsePositiveIntegerOption(assignment.key),
|
||||
targetConnectionId: parseSafeConnectionIdOption(assignment.value),
|
||||
};
|
||||
}
|
||||
|
||||
function collectMappingOption(
|
||||
value: string,
|
||||
previous: MetabaseSetupMappingAssignment[] = [],
|
||||
): MetabaseSetupMappingAssignment[] {
|
||||
return [...previous, parseMappingAssignment(value)];
|
||||
}
|
||||
|
||||
async function runMetabaseSetupArgs(
|
||||
context: KloCliCommandContext,
|
||||
args: KloConnectionMetabaseSetupArgs,
|
||||
): Promise<void> {
|
||||
const runner = context.deps.connectionMetabaseSetup ?? runKloConnectionMetabaseSetup;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerConnectionMetabaseCommands(connection: Command, context: KloCliCommandContext): void {
|
||||
const metabase = connection
|
||||
.command('metabase')
|
||||
.description('Configure Metabase connections')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
metabase.action(() => {
|
||||
metabase.outputHelp();
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
metabase
|
||||
.command('setup')
|
||||
.description('Guided setup for a Metabase connection')
|
||||
.option('--id <connectionId>', 'KLO connection id to write', parseSafeConnectionIdOption)
|
||||
.option('--url <url>', 'Metabase API URL')
|
||||
.addOption(new Option('--api-key <key>', 'Metabase API key').conflicts('mintApiKey'))
|
||||
.option('--mint-api-key', 'Mint a Metabase API key with credentials', false)
|
||||
.option('--username <email>', 'Metabase admin username for API-key minting')
|
||||
.option('--password <password>', 'Metabase admin password for API-key minting')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nGuided equivalent of:\n' +
|
||||
' klo connection mapping refresh <connectionId> --auto-accept\n' +
|
||||
' klo connection mapping set <connectionId> databaseMappings <id>=<target>\n' +
|
||||
' klo connection mapping set-sync-enabled <connectionId> <id> --enabled true\n' +
|
||||
' klo ingest <connectionId>\n',
|
||||
)
|
||||
.option(
|
||||
'--map <metabaseDatabaseId=targetConnectionId>',
|
||||
'Assign a Metabase database id to a warehouse connection; repeatable',
|
||||
collectMappingOption,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
'--sync <metabaseDatabaseId>',
|
||||
'Enable Metabase sync for a discovered database; repeatable',
|
||||
collectPositiveIntegerOption,
|
||||
[],
|
||||
)
|
||||
.addOption(
|
||||
new Option('--sync-mode <mode>', 'Metabase sync selection mode')
|
||||
.choices(SYNC_MODE_CHOICES)
|
||||
.default('ALL' satisfies MetabaseSetupSyncMode),
|
||||
)
|
||||
.option('--run-ingest', 'Run ingest after setup', false)
|
||||
.option('--yes', 'Confirm and apply setup changes without prompting', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.showHelpAfterError()
|
||||
.action(async (options: ConnectionMetabaseSetupOptions, command) => {
|
||||
await runMetabaseSetupArgs(context, {
|
||||
command: 'setup',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.id,
|
||||
url: options.url,
|
||||
apiKey: options.apiKey,
|
||||
mintApiKey: options.mintApiKey === true,
|
||||
metabaseUsername: options.username,
|
||||
metabasePassword: options.password,
|
||||
mappings: options.map,
|
||||
syncEnabledDatabaseIds: options.sync,
|
||||
syncMode: options.syncMode ?? 'ALL',
|
||||
runIngest: options.runIngest === true,
|
||||
yes: options.yes === true,
|
||||
inputMode: options.input === false ? 'disabled' : 'auto',
|
||||
});
|
||||
});
|
||||
}
|
||||
1136
packages/cli/src/commands/connection-metabase-setup.test.ts
Normal file
1136
packages/cli/src/commands/connection-metabase-setup.test.ts
Normal file
File diff suppressed because it is too large
Load diff
782
packages/cli/src/commands/connection-metabase-setup.ts
Normal file
782
packages/cli/src/commands/connection-metabase-setup.ts
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
import type { Option as ClackOption } from '@clack/prompts';
|
||||
import {
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
multiselect,
|
||||
note,
|
||||
outro,
|
||||
password,
|
||||
select,
|
||||
text,
|
||||
} from '@clack/prompts';
|
||||
import { localConnectionToWarehouseDescriptor } from '@klo/context/connections';
|
||||
import {
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
DefaultMetabaseConnectionClientFactory,
|
||||
LocalMetabaseSourceStateReader,
|
||||
MetabaseClient,
|
||||
type MetabaseDatabase,
|
||||
type MetabaseRuntimeClient,
|
||||
type MetabaseSyncMode,
|
||||
metabaseRuntimeConfigFromLocalConnection,
|
||||
validateMappingPhysicalMatch,
|
||||
} from '@klo/context/ingest';
|
||||
import {
|
||||
type KloLocalProject,
|
||||
type KloProjectConnectionConfig,
|
||||
kloLocalStateDbPath,
|
||||
loadKloProject,
|
||||
serializeKloProjectConfig,
|
||||
} from '@klo/context/project';
|
||||
|
||||
import { createClackSpinner, type KloCliSpinner } from '../clack.js';
|
||||
import type { KloCliIo } from '../cli-runtime.js';
|
||||
import { withMenuOptionsSpacing, withMultiselectNavigation } from '../prompt-navigation.js';
|
||||
import { type KloPublicIngestArgs, runKloPublicIngest } from '../public-ingest.js';
|
||||
|
||||
export type KloMetabaseSetupInputMode = 'auto' | 'disabled';
|
||||
|
||||
export type MetabaseSetupSyncMode = MetabaseSyncMode;
|
||||
|
||||
type MetabaseSetupPromptOption<Value> = ClackOption<Value>;
|
||||
|
||||
export interface MetabaseSetupLogger {
|
||||
info(message: string): void;
|
||||
step(message: string): void;
|
||||
success(message: string): void;
|
||||
warn(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export interface MetabaseSetupPromptAdapter {
|
||||
intro(title?: string): void;
|
||||
outro(message?: string): void;
|
||||
note(message: string, title: string): void;
|
||||
log: MetabaseSetupLogger;
|
||||
spinner(): KloCliSpinner;
|
||||
select<T extends string>(options: { message: string; options: Array<MetabaseSetupPromptOption<T>> }): Promise<T>;
|
||||
multiselect<Value extends number | string>(options: {
|
||||
message: string;
|
||||
options: Array<MetabaseSetupPromptOption<Value>>;
|
||||
initialValues?: Value[];
|
||||
required?: boolean;
|
||||
maxItems?: number;
|
||||
}): Promise<Value[]>;
|
||||
text(options: { message: string; placeholder?: string }): Promise<string>;
|
||||
password(options: { message: string }): Promise<string>;
|
||||
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
type KloMetabaseSetupInteractiveIo = KloCliIo & {
|
||||
stdin?: { isTTY?: boolean };
|
||||
};
|
||||
|
||||
export interface MetabaseSetupMappingAssignment {
|
||||
metabaseDatabaseId: number;
|
||||
targetConnectionId: string;
|
||||
}
|
||||
|
||||
export interface MintMetabaseApiKeyArgs {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type MintMetabaseApiKey = (args: MintMetabaseApiKeyArgs, io: KloCliIo) => Promise<string>;
|
||||
|
||||
export interface KloConnectionMetabaseSetupArgs {
|
||||
command: 'setup';
|
||||
projectDir: string;
|
||||
connectionId?: string;
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
mintApiKey: boolean;
|
||||
metabaseUsername?: string;
|
||||
metabasePassword?: string;
|
||||
mappings: MetabaseSetupMappingAssignment[];
|
||||
syncEnabledDatabaseIds: number[];
|
||||
syncMode: MetabaseSetupSyncMode;
|
||||
runIngest: boolean;
|
||||
yes: boolean;
|
||||
inputMode: KloMetabaseSetupInputMode;
|
||||
}
|
||||
|
||||
export interface KloConnectionMetabaseSetupDeps {
|
||||
createMetabaseClient?: (
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
) => Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>>;
|
||||
mintMetabaseApiKey?: MintMetabaseApiKey;
|
||||
prompts?: MetabaseSetupPromptAdapter;
|
||||
runPublicIngest?: (args: Extract<KloPublicIngestArgs, { command: 'run' }>, io: KloCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
function isMetabaseConnection(connection: KloProjectConnectionConfig | undefined): boolean {
|
||||
return (
|
||||
String(connection?.driver ?? '')
|
||||
.trim()
|
||||
.toLowerCase() === 'metabase'
|
||||
);
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: number[]): number[] {
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function resolveMetabaseUrl(connection: KloProjectConnectionConfig | undefined): string | undefined {
|
||||
return stringField(connection?.api_url) ?? stringField(connection?.apiUrl) ?? stringField(connection?.url);
|
||||
}
|
||||
|
||||
function resolveLiteralMetabaseApiKey(connection: KloProjectConnectionConfig | undefined): string | undefined {
|
||||
return stringField(connection?.api_key) ?? stringField(connection?.apiKey);
|
||||
}
|
||||
|
||||
function listMetabaseConnectionIds(project: KloLocalProject): string[] {
|
||||
return Object.entries(project.config.connections)
|
||||
.filter(([_connectionId, connection]) => isMetabaseConnection(connection))
|
||||
.map(([connectionId]) => connectionId)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function listWarehouseConnectionIds(project: KloLocalProject): string[] {
|
||||
return Object.entries(project.config.connections)
|
||||
.filter(([connectionId, connection]) => localConnectionToWarehouseDescriptor(connectionId, connection) != null)
|
||||
.map(([connectionId]) => connectionId)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function redactSecrets(message: string, secrets: string[]): string {
|
||||
let result = message;
|
||||
for (const secret of secrets) {
|
||||
if (!secret) {
|
||||
continue;
|
||||
}
|
||||
result = result.split(secret).join('[redacted]');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createDefaultMetabaseClient(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> {
|
||||
const factory = new DefaultMetabaseConnectionClientFactory(
|
||||
(metabaseConnectionId) =>
|
||||
metabaseRuntimeConfigFromLocalConnection(metabaseConnectionId, project.config.connections[metabaseConnectionId]),
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
);
|
||||
return factory.createClient(connectionId);
|
||||
}
|
||||
|
||||
async function defaultMintMetabaseApiKey(args: MintMetabaseApiKeyArgs): Promise<string> {
|
||||
const loginClient = new MetabaseClient({ apiUrl: args.url, apiKey: '' }, DEFAULT_METABASE_CLIENT_CONFIG);
|
||||
const sessionId = await loginClient.createSession(args.username, args.password);
|
||||
const sessionClient = new MetabaseClient(
|
||||
{ apiUrl: args.url, apiKey: sessionId, authHeaderName: 'X-Metabase-Session' },
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
);
|
||||
const groups = await sessionClient.getPermissionGroups();
|
||||
const adminGroup = groups.find((group) => group.name === 'Administrators');
|
||||
|
||||
if (!adminGroup) {
|
||||
throw new Error('Metabase Administrators group was not found; create an API key manually and pass --api-key');
|
||||
}
|
||||
|
||||
const mintedKey = await sessionClient.createApiKey({
|
||||
groupId: adminGroup.id,
|
||||
name: `KLO CLI ${new Date().toISOString()}`,
|
||||
});
|
||||
const trimmedKey = stringField(mintedKey);
|
||||
if (!trimmedKey) {
|
||||
throw new Error('Metabase API key minting returned an empty key');
|
||||
}
|
||||
return trimmedKey;
|
||||
}
|
||||
|
||||
function ensureNotCancelled<T>(value: T | symbol, prompts: Pick<MetabaseSetupPromptAdapter, 'cancel'>): T {
|
||||
if (isCancel(value)) {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
throw new Error('Setup cancelled.');
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function createClackMetabaseSetupPromptAdapter(): MetabaseSetupPromptAdapter {
|
||||
return {
|
||||
intro(title?: string): void {
|
||||
intro(title);
|
||||
},
|
||||
outro(message?: string): void {
|
||||
outro(message);
|
||||
},
|
||||
note(message: string, title: string): void {
|
||||
note(message, title);
|
||||
},
|
||||
log: {
|
||||
info(message: string): void {
|
||||
log.info(message);
|
||||
},
|
||||
step(message: string): void {
|
||||
log.step(message);
|
||||
},
|
||||
success(message: string): void {
|
||||
log.success(message);
|
||||
},
|
||||
warn(message: string): void {
|
||||
log.warn(message);
|
||||
},
|
||||
error(message: string): void {
|
||||
log.error(message);
|
||||
},
|
||||
},
|
||||
spinner(): KloCliSpinner {
|
||||
return createClackSpinner();
|
||||
},
|
||||
async select<T extends string>(options: {
|
||||
message: string;
|
||||
options: Array<MetabaseSetupPromptOption<T>>;
|
||||
}): Promise<T> {
|
||||
return ensureNotCancelled(await select(withMenuOptionsSpacing(options)), this);
|
||||
},
|
||||
async multiselect<Value extends number | string>(options: {
|
||||
message: string;
|
||||
options: Array<MetabaseSetupPromptOption<Value>>;
|
||||
initialValues?: Value[];
|
||||
required?: boolean;
|
||||
maxItems?: number;
|
||||
}): Promise<Value[]> {
|
||||
return ensureNotCancelled(await multiselect(withMenuOptionsSpacing(options)), this);
|
||||
},
|
||||
async text(options: { message: string; placeholder?: string }): Promise<string> {
|
||||
return ensureNotCancelled(await text(options), this);
|
||||
},
|
||||
async password(options: { message: string }): Promise<string> {
|
||||
return ensureNotCancelled(await password(options), this);
|
||||
},
|
||||
async confirm(options: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
return ensureNotCancelled(await confirm(options), this);
|
||||
},
|
||||
cancel(message: string): void {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractiveMetabaseSetupIo(
|
||||
args: Pick<KloConnectionMetabaseSetupArgs, 'inputMode'>,
|
||||
io: KloMetabaseSetupInteractiveIo,
|
||||
): boolean {
|
||||
return args.inputMode !== 'disabled' && io.stdin?.isTTY === true && io.stdout.isTTY === true;
|
||||
}
|
||||
|
||||
function normalizeDiscoveredDatabases(databases: MetabaseDatabase[]): Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
engine: string;
|
||||
host: string | null;
|
||||
dbName: string | null;
|
||||
}> {
|
||||
return databases
|
||||
.filter((database) => database.is_sample !== true)
|
||||
.map((database) => ({
|
||||
id: database.id,
|
||||
name: database.name,
|
||||
engine: stringField(database.engine) ?? 'unknown',
|
||||
host: stringField(database.details?.host) ?? null,
|
||||
dbName: stringField(database.details?.dbname) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function targetPhysicalInfo(project: KloLocalProject, connectionId: string) {
|
||||
const descriptor = localConnectionToWarehouseDescriptor(connectionId, project.config.connections[connectionId]);
|
||||
if (!descriptor) {
|
||||
return { connection_type: 'UNKNOWN' };
|
||||
}
|
||||
return {
|
||||
connection_type: descriptor.connection_type,
|
||||
host: descriptor.host ?? null,
|
||||
database: descriptor.database ?? null,
|
||||
account: descriptor.account ?? null,
|
||||
project_id: descriptor.project_id ?? null,
|
||||
dataset_id: descriptor.dataset_id ?? null,
|
||||
...descriptor.connection_params,
|
||||
};
|
||||
}
|
||||
|
||||
function noteMetabaseSetupSummary(options: {
|
||||
prompts: MetabaseSetupPromptAdapter;
|
||||
connectionId: string;
|
||||
url: string;
|
||||
mappings: MetabaseSetupMappingAssignment[];
|
||||
syncEnabledDatabaseIds: number[];
|
||||
}): void {
|
||||
const mappingLines = options.mappings
|
||||
.map((mapping) => ` ${mapping.metabaseDatabaseId} -> ${mapping.targetConnectionId}`)
|
||||
.join('\n');
|
||||
const syncLines = options.syncEnabledDatabaseIds.map((id) => ` ${id}`).join('\n');
|
||||
|
||||
options.prompts.note(
|
||||
[
|
||||
`Connection: ${options.connectionId}`,
|
||||
`URL: ${options.url}`,
|
||||
'',
|
||||
'Mappings:',
|
||||
mappingLines || ' (none)',
|
||||
'',
|
||||
'Sync enabled:',
|
||||
syncLines || ' (none)',
|
||||
].join('\n'),
|
||||
'Summary',
|
||||
);
|
||||
}
|
||||
|
||||
export async function runKloConnectionMetabaseSetup(
|
||||
args: KloConnectionMetabaseSetupArgs,
|
||||
io: KloCliIo,
|
||||
deps: KloConnectionMetabaseSetupDeps = {},
|
||||
): Promise<number> {
|
||||
let apiKeyForRedaction = args.apiKey;
|
||||
let passwordForRedaction = args.metabasePassword;
|
||||
const interactiveIo = io as KloMetabaseSetupInteractiveIo;
|
||||
const isInteractive = isInteractiveMetabaseSetupIo(args, interactiveIo);
|
||||
const prompts = deps.prompts ?? (isInteractive ? createClackMetabaseSetupPromptAdapter() : undefined);
|
||||
|
||||
try {
|
||||
if (isInteractive && prompts) {
|
||||
prompts.intro('KLO Metabase setup');
|
||||
}
|
||||
|
||||
const project = await loadKloProject({ projectDir: args.projectDir });
|
||||
const existingMetabaseConnectionIds = listMetabaseConnectionIds(project);
|
||||
let connectionId: string;
|
||||
|
||||
if (args.connectionId) {
|
||||
connectionId = args.connectionId;
|
||||
} else if (existingMetabaseConnectionIds.length === 1) {
|
||||
const onlyMetabaseConnectionId = existingMetabaseConnectionIds[0];
|
||||
if (!onlyMetabaseConnectionId) {
|
||||
throw new Error('No Metabase connection id was resolved');
|
||||
}
|
||||
connectionId = onlyMetabaseConnectionId;
|
||||
} else if (existingMetabaseConnectionIds.length > 1) {
|
||||
if (!isInteractive || !prompts) {
|
||||
throw new Error(
|
||||
`Multiple Metabase connections found (${existingMetabaseConnectionIds.join(', ')}); select one with --id`,
|
||||
);
|
||||
}
|
||||
connectionId = await prompts.select({
|
||||
message: 'Select the Metabase connection to configure',
|
||||
options: existingMetabaseConnectionIds.map((id) => ({ value: id, label: id })),
|
||||
});
|
||||
} else {
|
||||
connectionId = 'metabase';
|
||||
}
|
||||
|
||||
const existingConnection = project.config.connections[connectionId];
|
||||
const warehouseConnectionIds = listWarehouseConnectionIds(project);
|
||||
|
||||
if (warehouseConnectionIds.length === 0) {
|
||||
throw new Error('Add a warehouse connection first');
|
||||
}
|
||||
|
||||
let url = args.url ?? resolveMetabaseUrl(existingConnection);
|
||||
let apiKey = args.apiKey ?? resolveLiteralMetabaseApiKey(existingConnection);
|
||||
apiKeyForRedaction = apiKey;
|
||||
|
||||
if (!url && isInteractive && prompts) {
|
||||
url = stringField(
|
||||
await prompts.text({
|
||||
message: 'Metabase API URL',
|
||||
placeholder: 'http://localhost:3000',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (args.inputMode === 'disabled' && !url) {
|
||||
throw new Error('missing Metabase URL');
|
||||
}
|
||||
|
||||
if (!args.apiKey && !args.mintApiKey && apiKey && isInteractive && prompts && !args.yes) {
|
||||
const reuse = await prompts.confirm({
|
||||
message: `Reuse the existing Metabase API key from connections.${connectionId}?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!reuse) {
|
||||
apiKey = undefined;
|
||||
apiKeyForRedaction = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.mintApiKey) {
|
||||
let username = stringField(args.metabaseUsername);
|
||||
let metabasePassword = stringField(args.metabasePassword);
|
||||
|
||||
if (isInteractive && prompts) {
|
||||
if (!username) {
|
||||
username = stringField(await prompts.text({ message: 'Metabase admin username' }));
|
||||
}
|
||||
if (!metabasePassword) {
|
||||
metabasePassword = stringField(await prompts.password({ message: 'Metabase admin password' }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
throw new Error('--mint-api-key requires --username');
|
||||
}
|
||||
if (!metabasePassword) {
|
||||
throw new Error('--mint-api-key requires --password');
|
||||
}
|
||||
if (!url) {
|
||||
throw new Error('Metabase URL is required (use --url)');
|
||||
}
|
||||
|
||||
passwordForRedaction = metabasePassword;
|
||||
apiKey = await (deps.mintMetabaseApiKey ?? defaultMintMetabaseApiKey)(
|
||||
{ url, username, password: metabasePassword },
|
||||
io,
|
||||
);
|
||||
apiKeyForRedaction = apiKey;
|
||||
}
|
||||
|
||||
if (!apiKey && isInteractive && prompts) {
|
||||
const credentialMode = await prompts.select({
|
||||
message: 'Metabase credentials',
|
||||
options: [
|
||||
{ value: 'paste', label: 'Paste API key' },
|
||||
{ value: 'mint', label: 'Mint API key' },
|
||||
],
|
||||
});
|
||||
|
||||
if (credentialMode === 'paste') {
|
||||
apiKey = stringField(await prompts.password({ message: 'Metabase API key' }));
|
||||
apiKeyForRedaction = apiKey;
|
||||
} else {
|
||||
const username = stringField(await prompts.text({ message: 'Metabase admin username' }));
|
||||
const metabasePassword = stringField(await prompts.password({ message: 'Metabase admin password' }));
|
||||
if (!username) {
|
||||
throw new Error('Metabase username is required');
|
||||
}
|
||||
if (!metabasePassword) {
|
||||
throw new Error('Metabase password is required');
|
||||
}
|
||||
if (!url) {
|
||||
throw new Error('Metabase URL is required (use --url)');
|
||||
}
|
||||
|
||||
passwordForRedaction = metabasePassword;
|
||||
apiKey = await (deps.mintMetabaseApiKey ?? defaultMintMetabaseApiKey)(
|
||||
{ url, username, password: metabasePassword },
|
||||
io,
|
||||
);
|
||||
apiKeyForRedaction = apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.inputMode === 'disabled' && !apiKey) {
|
||||
throw new Error('missing Metabase API key');
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Metabase URL is required (use --url)');
|
||||
}
|
||||
if (!apiKey) {
|
||||
throw new Error('Metabase API key is required (use --api-key)');
|
||||
}
|
||||
|
||||
const transientConnectionConfig: KloProjectConnectionConfig = {
|
||||
...(existingConnection ?? {}),
|
||||
driver: 'metabase',
|
||||
api_url: url,
|
||||
api_key: apiKey,
|
||||
};
|
||||
const configWithTransient = {
|
||||
...project.config,
|
||||
connections: {
|
||||
...project.config.connections,
|
||||
[connectionId]: transientConnectionConfig,
|
||||
},
|
||||
};
|
||||
const discoveryProject: KloLocalProject = { ...project, config: configWithTransient };
|
||||
|
||||
for (const mapping of args.mappings) {
|
||||
if (!configWithTransient.connections[mapping.targetConnectionId]) {
|
||||
throw new Error(`Target connection "${mapping.targetConnectionId}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
const client = await (deps.createMetabaseClient ?? createDefaultMetabaseClient)(discoveryProject, connectionId);
|
||||
try {
|
||||
const authSpinner = isInteractive && prompts ? prompts.spinner() : undefined;
|
||||
authSpinner?.start('Testing Metabase connection');
|
||||
const testResult = await client.testConnection();
|
||||
if (!testResult.success) {
|
||||
authSpinner?.error('Metabase authentication failed');
|
||||
throw new Error(
|
||||
`Metabase authentication failed. Replace connections.${connectionId}.api_key or use --mint-api-key.`,
|
||||
);
|
||||
}
|
||||
authSpinner?.stop('Metabase reachable');
|
||||
|
||||
const discoverySpinner = isInteractive && prompts ? prompts.spinner() : undefined;
|
||||
discoverySpinner?.start('Discovering Metabase databases');
|
||||
const discovered = normalizeDiscoveredDatabases(await client.getDatabases());
|
||||
discoverySpinner?.stop(`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}`);
|
||||
if (isInteractive && prompts) {
|
||||
prompts.log.success(
|
||||
`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}`,
|
||||
);
|
||||
}
|
||||
if (discovered.length === 0) {
|
||||
throw new Error('Metabase auth worked but no usable databases were returned');
|
||||
}
|
||||
|
||||
let resolvedMappings = args.mappings;
|
||||
let resolvedSyncEnabledDatabaseIds = args.syncEnabledDatabaseIds;
|
||||
|
||||
if (resolvedSyncEnabledDatabaseIds.length === 0 && args.yes && resolvedMappings.length > 0) {
|
||||
resolvedSyncEnabledDatabaseIds = uniqueSorted(resolvedMappings.map((mapping) => mapping.metabaseDatabaseId));
|
||||
}
|
||||
|
||||
if (resolvedMappings.length === 0 && resolvedSyncEnabledDatabaseIds.length === 0) {
|
||||
const onlyDiscoveredDatabase = discovered.length === 1 ? discovered[0] : undefined;
|
||||
const compatibleWarehouses = onlyDiscoveredDatabase
|
||||
? warehouseConnectionIds.filter((warehouseConnectionId) => {
|
||||
const mismatchReason = validateMappingPhysicalMatch(
|
||||
{
|
||||
metabaseEngine: onlyDiscoveredDatabase.engine,
|
||||
metabaseDbName: onlyDiscoveredDatabase.dbName,
|
||||
metabaseHost: onlyDiscoveredDatabase.host,
|
||||
},
|
||||
targetPhysicalInfo(project, warehouseConnectionId),
|
||||
);
|
||||
return !mismatchReason;
|
||||
})
|
||||
: [];
|
||||
const onlyWarehouseConnectionId = compatibleWarehouses[0];
|
||||
|
||||
if (onlyDiscoveredDatabase && compatibleWarehouses.length === 1 && onlyWarehouseConnectionId) {
|
||||
if (args.yes) {
|
||||
resolvedMappings = [
|
||||
{ metabaseDatabaseId: onlyDiscoveredDatabase.id, targetConnectionId: onlyWarehouseConnectionId },
|
||||
];
|
||||
resolvedSyncEnabledDatabaseIds = [onlyDiscoveredDatabase.id];
|
||||
} else if (isInteractive && prompts) {
|
||||
const proposedMappings = [
|
||||
{ metabaseDatabaseId: onlyDiscoveredDatabase.id, targetConnectionId: onlyWarehouseConnectionId },
|
||||
];
|
||||
const proposedSyncEnabledDatabaseIds = [onlyDiscoveredDatabase.id];
|
||||
noteMetabaseSetupSummary({
|
||||
prompts,
|
||||
connectionId,
|
||||
url,
|
||||
mappings: proposedMappings,
|
||||
syncEnabledDatabaseIds: proposedSyncEnabledDatabaseIds,
|
||||
});
|
||||
const confirmed = await prompts.confirm({
|
||||
message: `Map Metabase database "${onlyDiscoveredDatabase.name}" (${onlyDiscoveredDatabase.id}) to "${onlyWarehouseConnectionId}" and enable sync?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
throw new Error('Setup cancelled.');
|
||||
}
|
||||
resolvedMappings = proposedMappings;
|
||||
resolvedSyncEnabledDatabaseIds = proposedSyncEnabledDatabaseIds;
|
||||
} else {
|
||||
throw new Error('Metabase mapping/sync is required in --no-input mode; pass --map and --sync');
|
||||
}
|
||||
} else if (isInteractive && prompts) {
|
||||
const selectedDatabaseIds = await prompts.multiselect<number>({
|
||||
message: withMultiselectNavigation('Select Metabase databases to configure'),
|
||||
options: discovered.map((database) => ({
|
||||
value: database.id,
|
||||
label: `${database.id}: ${database.name}`,
|
||||
hint: [database.engine, database.host, database.dbName].filter(Boolean).join(' • '),
|
||||
})),
|
||||
required: true,
|
||||
});
|
||||
|
||||
resolvedMappings = [];
|
||||
for (const databaseId of selectedDatabaseIds) {
|
||||
const database = discovered.find((candidate) => candidate.id === databaseId);
|
||||
if (!database) {
|
||||
throw new Error(`Selected database id ${databaseId} was not discovered`);
|
||||
}
|
||||
|
||||
const existingMapping = args.mappings.find((mapping) => mapping.metabaseDatabaseId === databaseId);
|
||||
if (existingMapping) {
|
||||
resolvedMappings.push(existingMapping);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetConnectionId = await prompts.select({
|
||||
message: `Map Metabase database ${database.id} ("${database.name}") to which KLO connection?`,
|
||||
options: warehouseConnectionIds.map((warehouseId) => ({ value: warehouseId, label: warehouseId })),
|
||||
});
|
||||
resolvedMappings.push({ metabaseDatabaseId: databaseId, targetConnectionId });
|
||||
}
|
||||
|
||||
const syncIds = await prompts.multiselect<number>({
|
||||
message: withMultiselectNavigation('Enable sync for which databases?'),
|
||||
options: selectedDatabaseIds.map((id) => ({ value: id, label: String(id) })),
|
||||
initialValues: selectedDatabaseIds,
|
||||
required: true,
|
||||
});
|
||||
resolvedSyncEnabledDatabaseIds = uniqueSorted(syncIds);
|
||||
|
||||
if (!args.yes) {
|
||||
noteMetabaseSetupSummary({
|
||||
prompts,
|
||||
connectionId,
|
||||
url,
|
||||
mappings: resolvedMappings,
|
||||
syncEnabledDatabaseIds: resolvedSyncEnabledDatabaseIds,
|
||||
});
|
||||
const confirmed = await prompts.confirm({
|
||||
message: 'Write changes to klo.yaml and enable sync?',
|
||||
initialValue: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
prompts.cancel('Setup cancelled.');
|
||||
throw new Error('Setup cancelled.');
|
||||
}
|
||||
}
|
||||
} else if (args.inputMode === 'disabled') {
|
||||
throw new Error('Metabase mapping/sync is required in --no-input mode; pass --map and --sync');
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
args.inputMode === 'disabled' &&
|
||||
resolvedMappings.length > 0 &&
|
||||
resolvedSyncEnabledDatabaseIds.length === 0
|
||||
) {
|
||||
throw new Error('Metabase sync selection is required in --no-input mode; pass --sync <metabaseDatabaseId>');
|
||||
}
|
||||
|
||||
const discoveredIds = new Set(discovered.map((database) => database.id));
|
||||
for (const mapping of resolvedMappings) {
|
||||
if (!discoveredIds.has(mapping.metabaseDatabaseId)) {
|
||||
throw new Error(`Mapped database id ${mapping.metabaseDatabaseId} was not discovered`);
|
||||
}
|
||||
}
|
||||
for (const syncId of resolvedSyncEnabledDatabaseIds) {
|
||||
if (!discoveredIds.has(syncId)) {
|
||||
throw new Error(`Sync database id ${syncId} was not discovered`);
|
||||
}
|
||||
}
|
||||
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(configWithTransient),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
`Setup Metabase connection ${connectionId}`,
|
||||
);
|
||||
|
||||
const updatedProject = await loadKloProject({ projectDir: args.projectDir });
|
||||
const store = new LocalMetabaseSourceStateReader({ dbPath: kloLocalStateDbPath(updatedProject) });
|
||||
|
||||
await store.refreshDiscoveredDatabases({ connectionId, discovered });
|
||||
|
||||
for (const mapping of resolvedMappings) {
|
||||
await store.upsertDatabaseMapping({
|
||||
connectionId,
|
||||
metabaseDatabaseId: mapping.metabaseDatabaseId,
|
||||
targetConnectionId: mapping.targetConnectionId,
|
||||
syncEnabled: false,
|
||||
source: 'cli',
|
||||
});
|
||||
}
|
||||
|
||||
for (const metabaseDatabaseId of resolvedSyncEnabledDatabaseIds) {
|
||||
await store.setMappingSyncEnabled({
|
||||
connectionId,
|
||||
metabaseDatabaseId,
|
||||
syncEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
const existingSyncState = await store.getSourceState(connectionId);
|
||||
await store.setSyncState({
|
||||
connectionId,
|
||||
syncMode: args.syncMode,
|
||||
defaultTagNames: existingSyncState.defaultTagNames,
|
||||
selections: existingSyncState.selections,
|
||||
});
|
||||
|
||||
const unhydrated = await store.getUnhydratedSyncEnabledMappingIds(connectionId);
|
||||
if (unhydrated.length > 0) {
|
||||
io.stderr.write(
|
||||
`Sync-enabled mappings are missing discovery metadata; run klo connection mapping refresh ${connectionId} --auto-accept\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const rows = await store.listDatabaseMappings(connectionId);
|
||||
const physicalFailures = rows.flatMap((row) => {
|
||||
if (!row.targetConnectionId) {
|
||||
return [];
|
||||
}
|
||||
const reason = validateMappingPhysicalMatch(
|
||||
{ metabaseEngine: row.metabaseEngine, metabaseDbName: row.metabaseDbName, metabaseHost: row.metabaseHost },
|
||||
updatedProject.config.connections[row.targetConnectionId]
|
||||
? targetPhysicalInfo(updatedProject, row.targetConnectionId)
|
||||
: { connection_type: 'UNKNOWN' },
|
||||
);
|
||||
return reason ? [`${row.metabaseDatabaseId}: ${reason}`] : [];
|
||||
});
|
||||
if (physicalFailures.length > 0) {
|
||||
for (const failure of physicalFailures) {
|
||||
io.stderr.write(`${failure}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
io.stdout.write(`Connection: ${connectionId}\n`);
|
||||
io.stdout.write(`Discovered ${discovered.length} ${discovered.length === 1 ? 'database' : 'databases'}\n`);
|
||||
io.stdout.write(`Next: klo ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
|
||||
if (args.runIngest) {
|
||||
const ingestRunner = deps.runPublicIngest ?? runKloPublicIngest;
|
||||
const exitCode = await ingestRunner(
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
targetConnectionId: connectionId,
|
||||
all: false,
|
||||
json: false,
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
io,
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
io.stderr.write(`Ingest failed; re-run: klo ingest ${connectionId} --project-dir ${args.projectDir}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (isInteractive && prompts) {
|
||||
prompts.outro('Metabase setup complete');
|
||||
}
|
||||
|
||||
return 0;
|
||||
} finally {
|
||||
await client.cleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
io.stderr.write(
|
||||
`${redactSecrets(message, [apiKeyForRedaction ?? '', passwordForRedaction ?? '', args.apiKey ?? ''])}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
92
packages/cli/src/commands/connection-notion-commands.ts
Normal file
92
packages/cli/src/commands/connection-notion-commands.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { type Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import { collectOption, type KloCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KloConnectionNotionArgs } from './connection-notion.js';
|
||||
|
||||
interface NotionPickOptions {
|
||||
input?: boolean;
|
||||
rootPageId: string[];
|
||||
}
|
||||
|
||||
function parseSafeConnectionId(value: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
|
||||
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uniqueInOrder(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!seen.has(value)) {
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeNotionPageId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const compact = trimmed.includes('-') ? trimmed.replace(/-/g, '') : trimmed;
|
||||
if (!/^[0-9a-fA-F]{32}$/.test(compact)) {
|
||||
throw new Error(`Invalid Notion page UUID: ${value}`);
|
||||
}
|
||||
const lower = compact.toLowerCase();
|
||||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
|
||||
}
|
||||
|
||||
function buildPickArgs(connectionId: string, projectDir: string, options: NotionPickOptions): KloConnectionNotionArgs {
|
||||
if (options.input !== false) {
|
||||
return {
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId,
|
||||
mode: 'interactive',
|
||||
};
|
||||
}
|
||||
|
||||
const rootPageIds = uniqueInOrder(options.rootPageId.map(normalizeNotionPageId));
|
||||
if (rootPageIds.length === 0) {
|
||||
throw new Error('connection notion pick --no-input requires at least one --root-page-id');
|
||||
}
|
||||
return {
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId,
|
||||
mode: 'non-interactive',
|
||||
rootPageIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function runConnectionNotionArgs(context: KloCliCommandContext, args: KloConnectionNotionArgs): Promise<void> {
|
||||
const runner = context.deps.connectionNotion ?? (await import('./connection-notion.js')).runKloConnectionNotion;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerConnectionNotionCommands(connect: Command, context: KloCliCommandContext): void {
|
||||
const notion = connect
|
||||
.command('notion')
|
||||
.description('Configure Notion source selection')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
notion.action(() => {
|
||||
notion.outputHelp();
|
||||
context.setExitCode(0);
|
||||
});
|
||||
|
||||
notion
|
||||
.command('pick')
|
||||
.description('Pick Notion root pages for a configured Notion connection')
|
||||
.argument('<connectionId>', 'Notion connection id', parseSafeConnectionId)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.option('--root-page-id <id>', 'Root page UUID to crawl; repeatable with --no-input', collectOption, [])
|
||||
.showHelpAfterError()
|
||||
.action(async (connectionId: string, options: NotionPickOptions, command) => {
|
||||
await runConnectionNotionArgs(context, buildPickArgs(connectionId, resolveCommandProjectDir(command), options));
|
||||
});
|
||||
}
|
||||
283
packages/cli/src/commands/connection-notion-tree.test.ts
Normal file
283
packages/cli/src/commands/connection-notion-tree.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildInitialState,
|
||||
buildPickerTree,
|
||||
canToggle,
|
||||
clearExpiredTransientHint,
|
||||
filterTree,
|
||||
flattenSelection,
|
||||
moveCursor,
|
||||
reducer,
|
||||
selectAllVisible,
|
||||
selectNone,
|
||||
toggleChecked,
|
||||
TRANSIENT_HINT_DURATION_MS,
|
||||
visibleNodeIds,
|
||||
type NotionPickerPageInput,
|
||||
} from './connection-notion-tree.js';
|
||||
|
||||
const IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
onboarding: '33333333-3333-3333-3333-333333333333',
|
||||
marketing: '44444444-4444-4444-4444-444444444444',
|
||||
journal: '55555555-5555-5555-5555-555555555555',
|
||||
orphan: '66666666-6666-6666-6666-666666666666',
|
||||
duplicate: '77777777-7777-7777-7777-777777777777',
|
||||
cycleA: '88888888-8888-8888-8888-888888888888',
|
||||
cycleB: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function pages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
{ id: IDS.onboarding, title: 'Onboarding', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.journal, title: 'Daily journal', archived: true, parentId: IDS.marketing },
|
||||
{ id: IDS.orphan, title: '', archived: false, parentId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' },
|
||||
{ id: IDS.duplicate, title: 'Original duplicate', archived: false, parentId: null },
|
||||
{ id: IDS.duplicate, title: 'Ignored duplicate', archived: true, parentId: IDS.marketing },
|
||||
{ id: IDS.cycleA, title: 'Cycle A', archived: false, parentId: IDS.cycleB },
|
||||
{ id: IDS.cycleB, title: 'Cycle B', archived: false, parentId: IDS.cycleA },
|
||||
];
|
||||
}
|
||||
|
||||
describe('buildPickerTree', () => {
|
||||
it('deduplicates pages, sorts siblings, preserves archived flags, roots orphans, and breaks cycles', () => {
|
||||
const tree = buildPickerTree(pages());
|
||||
const byId = new Map(tree.map((node) => [node.id, node]));
|
||||
|
||||
expect(tree.map((node) => node.title)).toEqual([
|
||||
'Cycle A',
|
||||
'Cycle B',
|
||||
'Engineering Docs',
|
||||
'Architecture',
|
||||
'Onboarding',
|
||||
'Marketing',
|
||||
'Daily journal',
|
||||
'Original duplicate',
|
||||
'Untitled',
|
||||
]);
|
||||
expect(byId.get(IDS.engineering)?.childIds).toEqual([IDS.architecture, IDS.onboarding]);
|
||||
expect(byId.get(IDS.architecture)).toMatchObject({
|
||||
depth: 1,
|
||||
parentId: IDS.engineering,
|
||||
path: 'Engineering Docs / Architecture',
|
||||
});
|
||||
expect(byId.get(IDS.journal)).toMatchObject({
|
||||
archived: true,
|
||||
depth: 1,
|
||||
path: 'Marketing / Daily journal',
|
||||
});
|
||||
expect(byId.get(IDS.orphan)).toMatchObject({
|
||||
title: 'Untitled',
|
||||
parentId: null,
|
||||
depth: 0,
|
||||
path: 'Untitled',
|
||||
});
|
||||
expect(byId.get(IDS.duplicate)).toMatchObject({
|
||||
title: 'Original duplicate',
|
||||
archived: false,
|
||||
parentId: null,
|
||||
});
|
||||
expect(byId.get(IDS.cycleA)?.parentId).toBeNull();
|
||||
expect(byId.get(IDS.cycleB)?.parentId).toBe(IDS.cycleA);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection invariants', () => {
|
||||
it('checking a parent locks descendants and keeps checked ids minimal', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
const checkedParent = toggleChecked(state, IDS.engineering, 1000);
|
||||
expect([...checkedParent.checked]).toEqual([IDS.engineering]);
|
||||
expect(canToggle(IDS.architecture, checkedParent)).toEqual({
|
||||
ok: false,
|
||||
reason: "Locked by 'Engineering Docs' - uncheck parent first",
|
||||
});
|
||||
|
||||
const lockedChildAttempt = toggleChecked(checkedParent, IDS.architecture, 2000);
|
||||
expect([...lockedChildAttempt.checked]).toEqual([IDS.engineering]);
|
||||
expect(lockedChildAttempt.transientHint).toEqual({
|
||||
text: "Locked by 'Engineering Docs' - uncheck parent first",
|
||||
expiresAt: 4500,
|
||||
});
|
||||
|
||||
const uncheckedParent = toggleChecked(lockedChildAttempt, IDS.engineering, 3000);
|
||||
expect([...uncheckedParent.checked]).toEqual([]);
|
||||
expect(canToggle(IDS.architecture, uncheckedParent)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('normalizes stored roots, reports stale roots, expands checked ancestors, and flattens descendants', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [
|
||||
IDS.engineering.replaceAll('-', ''),
|
||||
IDS.architecture,
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
expect([...state.checked]).toEqual([IDS.engineering]);
|
||||
expect([...state.expanded]).toEqual([]);
|
||||
expect(state.cursorId).toBe(IDS.cycleA);
|
||||
expect(state.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
|
||||
expect(flattenSelection(new Set([IDS.engineering, IDS.architecture]), state.byId)).toEqual([IDS.engineering]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search and cursor movement', () => {
|
||||
it('filters by title and path while deriving auto-expanded ancestors', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
const searching = {
|
||||
...state,
|
||||
search: { editing: false, query: 'architecture' },
|
||||
};
|
||||
|
||||
expect(filterTree(searching)).toEqual({
|
||||
visibleIds: new Set([IDS.engineering, IDS.architecture]),
|
||||
autoExpand: new Set([IDS.engineering]),
|
||||
});
|
||||
expect(visibleNodeIds(searching)).toEqual([IDS.engineering, IDS.architecture]);
|
||||
});
|
||||
|
||||
it('moves the cursor through visible nodes and implements left/right tree semantics', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
const atEngineering = {
|
||||
...state,
|
||||
cursorId: IDS.engineering,
|
||||
expanded: new Set([IDS.engineering]),
|
||||
};
|
||||
expect(moveCursor(atEngineering, 'down').cursorId).toBe(IDS.architecture);
|
||||
expect(moveCursor({ ...atEngineering, cursorId: IDS.architecture }, 'up').cursorId).toBe(IDS.engineering);
|
||||
expect(moveCursor(atEngineering, 'right').cursorId).toBe(IDS.architecture);
|
||||
expect(moveCursor({ ...atEngineering, cursorId: IDS.architecture }, 'left').cursorId).toBe(IDS.engineering);
|
||||
expect([...moveCursor(atEngineering, 'left').expanded]).toEqual([]);
|
||||
expect([...moveCursor({ ...state, cursorId: IDS.marketing }, 'right').expanded]).toContain(IDS.marketing);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk actions and reducer effects', () => {
|
||||
it('selects only matching visible roots under search and clears selection', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [IDS.marketing],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
const searching = {
|
||||
...state,
|
||||
search: { editing: false, query: 'architecture' },
|
||||
};
|
||||
|
||||
const selected = selectAllVisible(searching);
|
||||
expect(flattenSelection(selected.checked, selected.byId)).toEqual([IDS.architecture, IDS.marketing]);
|
||||
expect([...selectNone(selected).checked]).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns save immediately for selected_roots and requires confirmation for all_accessible', () => {
|
||||
const selectedRoots = toggleChecked(
|
||||
buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
}),
|
||||
IDS.marketing,
|
||||
1000,
|
||||
);
|
||||
expect(reducer(selectedRoots, 'save-request')).toEqual({
|
||||
next: selectedRoots,
|
||||
effect: 'save',
|
||||
});
|
||||
|
||||
const allAccessible = {
|
||||
...selectedRoots,
|
||||
currentCrawlMode: 'all_accessible' as const,
|
||||
};
|
||||
const confirm = reducer(allAccessible, 'save-request');
|
||||
expect(confirm).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: 'mode-switch' },
|
||||
effect: null,
|
||||
});
|
||||
expect(reducer(confirm.next, 'save-cancel')).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: null },
|
||||
effect: null,
|
||||
});
|
||||
expect(reducer(confirm.next, 'save-confirm')).toEqual({
|
||||
next: { ...allAccessible, pendingConfirm: null },
|
||||
effect: 'save',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks empty saves, updates search state, and quits without saving', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
|
||||
const blockedSave = reducer(state, 'save-request', 9000);
|
||||
expect(blockedSave).toEqual({
|
||||
next: {
|
||||
...state,
|
||||
transientHint: {
|
||||
text: 'Select at least one page or press q to quit',
|
||||
expiresAt: 9000 + TRANSIENT_HINT_DURATION_MS,
|
||||
},
|
||||
},
|
||||
effect: null,
|
||||
});
|
||||
expect(
|
||||
reducer(
|
||||
reducer(reducer(state, 'search-start').next, { type: 'search-input', value: 'a' }).next,
|
||||
'search-submit',
|
||||
).next.search,
|
||||
).toEqual({ editing: false, query: 'a' });
|
||||
expect(reducer(state, 'quit')).toEqual({
|
||||
next: state,
|
||||
effect: 'quit-without-save',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears transient hints only when their expiry time has passed', () => {
|
||||
const state = buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
const withHint = {
|
||||
...state,
|
||||
transientHint: {
|
||||
text: 'Select at least one page or press q to quit',
|
||||
expiresAt: 11500,
|
||||
},
|
||||
};
|
||||
|
||||
expect(clearExpiredTransientHint(withHint, 11499)).toBe(withHint);
|
||||
expect(clearExpiredTransientHint(withHint, 11500)).toEqual({
|
||||
...withHint,
|
||||
transientHint: null,
|
||||
});
|
||||
expect(reducer(withHint, 'clear-transient-hint', 11501)).toEqual({
|
||||
next: {
|
||||
...withHint,
|
||||
transientHint: null,
|
||||
},
|
||||
effect: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
529
packages/cli/src/commands/connection-notion-tree.ts
Normal file
529
packages/cli/src/commands/connection-notion-tree.ts
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
export interface NotionPickerPageInput {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
archived?: boolean;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
interface NotionPickerNode {
|
||||
id: string;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
parentId: string | null;
|
||||
depth: number;
|
||||
childIds: string[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PickerState {
|
||||
tree: NotionPickerNode[];
|
||||
byId: Map<string, NotionPickerNode>;
|
||||
expanded: Set<string>;
|
||||
checked: Set<string>;
|
||||
cursorId: string;
|
||||
search: { editing: boolean; query: string };
|
||||
pendingConfirm: 'mode-switch' | null;
|
||||
preLoadWarnings: string[];
|
||||
transientHint: { text: string; expiresAt: number } | null;
|
||||
currentCrawlMode: 'all_accessible' | 'selected_roots';
|
||||
}
|
||||
|
||||
export type PickerCommand =
|
||||
| 'cursor-up'
|
||||
| 'cursor-down'
|
||||
| 'cursor-left'
|
||||
| 'cursor-right'
|
||||
| 'expand'
|
||||
| 'collapse'
|
||||
| 'expand-all'
|
||||
| 'collapse-all'
|
||||
| 'toggle-check'
|
||||
| 'select-all-visible'
|
||||
| 'select-none'
|
||||
| 'clear-transient-hint'
|
||||
| 'search-start'
|
||||
| 'search-cancel'
|
||||
| 'search-submit'
|
||||
| 'search-backspace'
|
||||
| { type: 'search-input'; value: string }
|
||||
| 'save-request'
|
||||
| 'save-confirm'
|
||||
| 'save-cancel'
|
||||
| 'quit';
|
||||
|
||||
type PickerEffect = null | 'save' | 'quit-without-save';
|
||||
|
||||
interface MutableNode {
|
||||
id: string;
|
||||
title: string;
|
||||
archived: boolean;
|
||||
parentId: string | null;
|
||||
childIds: string[];
|
||||
}
|
||||
|
||||
export const TRANSIENT_HINT_DURATION_MS = 2500;
|
||||
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
|
||||
|
||||
function normalizePageId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const compact = trimmed.replace(/-/g, '');
|
||||
if (/^[0-9a-fA-F]{32}$/.test(compact)) {
|
||||
const lower = compact.toLowerCase();
|
||||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(
|
||||
16,
|
||||
20,
|
||||
)}-${lower.slice(20)}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function titleValue(value: string | null | undefined): string {
|
||||
const trimmed = value?.trim() ?? '';
|
||||
return trimmed.length > 0 ? trimmed : 'Untitled';
|
||||
}
|
||||
|
||||
function sortedNodeIds(ids: string[], nodes: Map<string, MutableNode | NotionPickerNode>): string[] {
|
||||
return [...ids].sort((leftId, rightId) => {
|
||||
const left = nodes.get(leftId);
|
||||
const right = nodes.get(rightId);
|
||||
const byTitle = collator.compare(left?.title ?? '', right?.title ?? '');
|
||||
return byTitle === 0 ? leftId.localeCompare(rightId) : byTitle;
|
||||
});
|
||||
}
|
||||
|
||||
function cloneState(state: PickerState, patch: Partial<PickerState>): PickerState {
|
||||
return { ...state, ...patch };
|
||||
}
|
||||
|
||||
function transientHint(text: string, now: number): PickerState['transientHint'] {
|
||||
return { text, expiresAt: now + TRANSIENT_HINT_DURATION_MS };
|
||||
}
|
||||
|
||||
export function clearExpiredTransientHint(state: PickerState, now = Date.now()): PickerState {
|
||||
if (!state.transientHint || state.transientHint.expiresAt > now) {
|
||||
return state;
|
||||
}
|
||||
return cloneState(state, { transientHint: null });
|
||||
}
|
||||
|
||||
function ancestorsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
|
||||
const ancestors: string[] = [];
|
||||
let parentId = byId.get(nodeId)?.parentId ?? null;
|
||||
const seen = new Set<string>();
|
||||
while (parentId && !seen.has(parentId)) {
|
||||
ancestors.push(parentId);
|
||||
seen.add(parentId);
|
||||
parentId = byId.get(parentId)?.parentId ?? null;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function descendantsOf(nodeId: string, byId: Map<string, NotionPickerNode>): string[] {
|
||||
const result: string[] = [];
|
||||
const stack = [...(byId.get(nodeId)?.childIds ?? [])].reverse();
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop();
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
result.push(id);
|
||||
const node = byId.get(id);
|
||||
if (node) {
|
||||
stack.push(...[...node.childIds].reverse());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function matchingIds(state: PickerState): Set<string> {
|
||||
const query = state.search.query.trim().toLocaleLowerCase();
|
||||
if (!query) {
|
||||
return new Set(state.tree.map((node) => node.id));
|
||||
}
|
||||
return new Set(
|
||||
state.tree
|
||||
.filter((node) => {
|
||||
const title = node.title.toLocaleLowerCase();
|
||||
const path = node.path.toLocaleLowerCase();
|
||||
return title.includes(query) || path.includes(query);
|
||||
})
|
||||
.map((node) => node.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPickerTree(searchResults: NotionPickerPageInput[]): NotionPickerNode[] {
|
||||
const nodes = new Map<string, MutableNode>();
|
||||
for (const result of searchResults) {
|
||||
const id = normalizePageId(result.id);
|
||||
if (nodes.has(id)) {
|
||||
continue;
|
||||
}
|
||||
nodes.set(id, {
|
||||
id,
|
||||
title: titleValue(result.title),
|
||||
archived: result.archived === true,
|
||||
parentId: result.parentId ? normalizePageId(result.parentId) : null,
|
||||
childIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
if (!node.parentId || node.parentId === node.id || !nodes.has(node.parentId)) {
|
||||
node.parentId = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const seen = new Set([node.id]);
|
||||
let cursor: string | null = node.parentId;
|
||||
while (cursor) {
|
||||
if (seen.has(cursor)) {
|
||||
node.parentId = null;
|
||||
break;
|
||||
}
|
||||
seen.add(cursor);
|
||||
cursor = nodes.get(cursor)?.parentId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
node.childIds = [];
|
||||
}
|
||||
for (const node of nodes.values()) {
|
||||
if (node.parentId) {
|
||||
nodes.get(node.parentId)?.childIds.push(node.id);
|
||||
}
|
||||
}
|
||||
for (const node of nodes.values()) {
|
||||
node.childIds = sortedNodeIds(node.childIds, nodes);
|
||||
}
|
||||
|
||||
const roots = sortedNodeIds(
|
||||
[...nodes.values()].filter((node) => node.parentId === null).map((node) => node.id),
|
||||
nodes,
|
||||
);
|
||||
const tree: NotionPickerNode[] = [];
|
||||
|
||||
function visit(nodeId: string, depth: number, pathPrefix: string[]): void {
|
||||
const raw = nodes.get(nodeId);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const path = [...pathPrefix, raw.title].join(' / ');
|
||||
const node: NotionPickerNode = {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
archived: raw.archived,
|
||||
parentId: raw.parentId,
|
||||
depth,
|
||||
childIds: raw.childIds,
|
||||
path,
|
||||
};
|
||||
tree.push(node);
|
||||
for (const childId of raw.childIds) {
|
||||
visit(childId, depth + 1, [...pathPrefix, raw.title]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootId of roots) {
|
||||
visit(rootId, 0, []);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function isAncestorChecked(nodeId: string, checked: Set<string>, byId: Map<string, NotionPickerNode>): boolean {
|
||||
return ancestorsOf(nodeId, byId).some((ancestorId) => checked.has(ancestorId));
|
||||
}
|
||||
|
||||
function checkedAncestor(nodeId: string, state: PickerState): NotionPickerNode | null {
|
||||
for (const ancestorId of ancestorsOf(nodeId, state.byId)) {
|
||||
if (state.checked.has(ancestorId)) {
|
||||
return state.byId.get(ancestorId) ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canToggle(nodeId: string, state: PickerState): { ok: true } | { ok: false; reason: string } {
|
||||
if (!state.byId.has(nodeId)) {
|
||||
return { ok: false, reason: 'Page not found' };
|
||||
}
|
||||
const ancestor = checkedAncestor(nodeId, state);
|
||||
if (ancestor) {
|
||||
return { ok: false, reason: `Locked by '${ancestor.title}' - uncheck parent first` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function toggleChecked(state: PickerState, nodeId: string, now = Date.now()): PickerState {
|
||||
const toggle = canToggle(nodeId, state);
|
||||
if (!toggle.ok) {
|
||||
return cloneState(state, {
|
||||
transientHint: transientHint(toggle.reason, now),
|
||||
});
|
||||
}
|
||||
|
||||
const checked = new Set(state.checked);
|
||||
if (checked.has(nodeId)) {
|
||||
checked.delete(nodeId);
|
||||
} else {
|
||||
checked.add(nodeId);
|
||||
for (const descendantId of descendantsOf(nodeId, state.byId)) {
|
||||
checked.delete(descendantId);
|
||||
}
|
||||
}
|
||||
return cloneState(state, { checked, transientHint: null });
|
||||
}
|
||||
|
||||
export function flattenSelection(checked: Set<string>, byId: Map<string, NotionPickerNode>): string[] {
|
||||
const result: string[] = [];
|
||||
for (const node of byId.values()) {
|
||||
if (checked.has(node.id) && !isAncestorChecked(node.id, checked, byId)) {
|
||||
result.push(node.id);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function filterTree(state: PickerState): { visibleIds: Set<string>; autoExpand: Set<string> } {
|
||||
const matches = matchingIds(state);
|
||||
if (state.search.query.trim().length === 0) {
|
||||
return { visibleIds: matches, autoExpand: new Set() };
|
||||
}
|
||||
|
||||
const visibleIds = new Set<string>();
|
||||
const autoExpand = new Set<string>();
|
||||
for (const matchId of matches) {
|
||||
visibleIds.add(matchId);
|
||||
for (const ancestorId of ancestorsOf(matchId, state.byId)) {
|
||||
visibleIds.add(ancestorId);
|
||||
autoExpand.add(ancestorId);
|
||||
}
|
||||
}
|
||||
return { visibleIds, autoExpand };
|
||||
}
|
||||
|
||||
export function visibleNodeIds(state: PickerState): string[] {
|
||||
const { visibleIds, autoExpand } = filterTree(state);
|
||||
const result: string[] = [];
|
||||
const roots = state.tree.filter((node) => node.parentId === null).map((node) => node.id);
|
||||
|
||||
function visit(nodeId: string): void {
|
||||
if (!visibleIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
result.push(nodeId);
|
||||
const node = state.byId.get(nodeId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (state.expanded.has(nodeId) || autoExpand.has(nodeId)) {
|
||||
for (const childId of node.childIds) {
|
||||
visit(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootId of roots) {
|
||||
visit(rootId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function selectAllVisible(state: PickerState): PickerState {
|
||||
const candidates = state.search.query.trim().length > 0 ? matchingIds(state) : new Set(visibleNodeIds(state));
|
||||
const checked = new Set(state.checked);
|
||||
|
||||
for (const node of state.tree) {
|
||||
if (!candidates.has(node.id)) {
|
||||
continue;
|
||||
}
|
||||
const hasCandidateAncestor = ancestorsOf(node.id, state.byId).some((ancestorId) => candidates.has(ancestorId));
|
||||
if (!hasCandidateAncestor && !isAncestorChecked(node.id, checked, state.byId)) {
|
||||
checked.add(node.id);
|
||||
for (const descendantId of descendantsOf(node.id, state.byId)) {
|
||||
checked.delete(descendantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cloneState(state, {
|
||||
checked: new Set(flattenSelection(checked, state.byId)),
|
||||
transientHint: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function selectNone(state: PickerState): PickerState {
|
||||
return cloneState(state, { checked: new Set(), transientHint: null });
|
||||
}
|
||||
|
||||
function setExpanded(state: PickerState, nodeId: string, value: boolean | 'toggle'): PickerState {
|
||||
const expanded = new Set(state.expanded);
|
||||
const nextValue = value === 'toggle' ? !expanded.has(nodeId) : value;
|
||||
if (nextValue) {
|
||||
expanded.add(nodeId);
|
||||
} else {
|
||||
expanded.delete(nodeId);
|
||||
}
|
||||
return cloneState(state, { expanded });
|
||||
}
|
||||
|
||||
function expandPath(state: PickerState, nodeId: string): PickerState {
|
||||
const expanded = new Set(state.expanded);
|
||||
for (const ancestorId of ancestorsOf(nodeId, state.byId)) {
|
||||
expanded.add(ancestorId);
|
||||
}
|
||||
return cloneState(state, { expanded });
|
||||
}
|
||||
|
||||
export function moveCursor(state: PickerState, dir: 'up' | 'down' | 'left' | 'right'): PickerState {
|
||||
const node = state.byId.get(state.cursorId);
|
||||
if (!node) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (dir === 'left') {
|
||||
if (node.childIds.length > 0 && state.expanded.has(node.id)) {
|
||||
return setExpanded(state, node.id, false);
|
||||
}
|
||||
return node.parentId ? cloneState(state, { cursorId: node.parentId }) : state;
|
||||
}
|
||||
|
||||
if (dir === 'right') {
|
||||
if (node.childIds.length === 0) {
|
||||
return state;
|
||||
}
|
||||
if (!state.expanded.has(node.id)) {
|
||||
return setExpanded(state, node.id, true);
|
||||
}
|
||||
return cloneState(state, { cursorId: node.childIds[0] ?? node.id });
|
||||
}
|
||||
|
||||
const ids = visibleNodeIds(state);
|
||||
const index = ids.indexOf(state.cursorId);
|
||||
if (index === -1) {
|
||||
return ids[0] ? cloneState(state, { cursorId: ids[0] }) : state;
|
||||
}
|
||||
const nextIndex = dir === 'up' ? Math.max(0, index - 1) : Math.min(ids.length - 1, index + 1);
|
||||
return cloneState(state, { cursorId: ids[nextIndex] ?? state.cursorId });
|
||||
}
|
||||
|
||||
export function buildInitialState(args: {
|
||||
tree: NotionPickerNode[];
|
||||
existingRootPageIds: string[];
|
||||
currentCrawlMode?: 'all_accessible' | 'selected_roots';
|
||||
}): PickerState {
|
||||
const byId = new Map(args.tree.map((node) => [node.id, node]));
|
||||
const checked = new Set<string>();
|
||||
let staleCount = 0;
|
||||
|
||||
for (const rawId of args.existingRootPageIds) {
|
||||
const id = normalizePageId(rawId);
|
||||
if (byId.has(id)) {
|
||||
checked.add(id);
|
||||
} else {
|
||||
staleCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const minimalChecked = new Set(flattenSelection(checked, byId));
|
||||
const expanded = new Set<string>();
|
||||
for (const checkedId of minimalChecked) {
|
||||
for (const ancestorId of ancestorsOf(checkedId, byId)) {
|
||||
expanded.add(ancestorId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tree: args.tree,
|
||||
byId,
|
||||
expanded,
|
||||
checked: minimalChecked,
|
||||
cursorId: args.tree[0]?.id ?? '',
|
||||
search: { editing: false, query: '' },
|
||||
pendingConfirm: null,
|
||||
preLoadWarnings: staleCount > 0 ? [`${staleCount} stored root_page_ids no longer visible`] : [],
|
||||
transientHint: null,
|
||||
currentCrawlMode: args.currentCrawlMode ?? 'selected_roots',
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(state: PickerState, cmd: PickerCommand, now = Date.now()): { next: PickerState; effect: PickerEffect } {
|
||||
if (state.pendingConfirm) {
|
||||
if (cmd === 'save-confirm') {
|
||||
return { next: cloneState(state, { pendingConfirm: null }), effect: 'save' };
|
||||
}
|
||||
if (cmd === 'save-cancel') {
|
||||
return { next: cloneState(state, { pendingConfirm: null }), effect: null };
|
||||
}
|
||||
if (cmd === 'quit') {
|
||||
return { next: state, effect: 'quit-without-save' };
|
||||
}
|
||||
return { next: state, effect: null };
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'cursor-up':
|
||||
return { next: moveCursor(state, 'up'), effect: null };
|
||||
case 'cursor-down':
|
||||
return { next: moveCursor(state, 'down'), effect: null };
|
||||
case 'cursor-left':
|
||||
return { next: moveCursor(state, 'left'), effect: null };
|
||||
case 'cursor-right':
|
||||
return { next: moveCursor(state, 'right'), effect: null };
|
||||
case 'expand':
|
||||
return { next: setExpanded(state, state.cursorId, 'toggle'), effect: null };
|
||||
case 'collapse':
|
||||
return { next: setExpanded(state, state.cursorId, false), effect: null };
|
||||
case 'expand-all':
|
||||
return {
|
||||
next: cloneState(state, {
|
||||
expanded: new Set(state.tree.filter((node) => node.childIds.length > 0).map((node) => node.id)),
|
||||
}),
|
||||
effect: null,
|
||||
};
|
||||
case 'collapse-all':
|
||||
return { next: cloneState(state, { expanded: new Set() }), effect: null };
|
||||
case 'toggle-check':
|
||||
return { next: toggleChecked(state, state.cursorId, now), effect: null };
|
||||
case 'select-all-visible':
|
||||
return { next: selectAllVisible(state), effect: null };
|
||||
case 'select-none':
|
||||
return { next: selectNone(state), effect: null };
|
||||
case 'clear-transient-hint':
|
||||
return { next: clearExpiredTransientHint(state, now), effect: null };
|
||||
case 'search-start':
|
||||
return { next: cloneState(state, { search: { ...state.search, editing: true } }), effect: null };
|
||||
case 'search-cancel':
|
||||
return { next: cloneState(state, { search: { editing: false, query: '' } }), effect: null };
|
||||
case 'search-submit':
|
||||
return { next: cloneState(state, { search: { ...state.search, editing: false } }), effect: null };
|
||||
case 'search-backspace':
|
||||
return {
|
||||
next: cloneState(state, { search: { ...state.search, query: state.search.query.slice(0, -1) } }),
|
||||
effect: null,
|
||||
};
|
||||
case 'save-request':
|
||||
if (state.checked.size === 0) {
|
||||
return {
|
||||
next: cloneState(state, {
|
||||
transientHint: transientHint('Select at least one page or press q to quit', now),
|
||||
}),
|
||||
effect: null,
|
||||
};
|
||||
}
|
||||
if (state.currentCrawlMode === 'all_accessible') {
|
||||
return { next: cloneState(state, { pendingConfirm: 'mode-switch' }), effect: null };
|
||||
}
|
||||
return { next: state, effect: 'save' };
|
||||
case 'save-confirm':
|
||||
return { next: state, effect: 'save' };
|
||||
case 'save-cancel':
|
||||
return { next: state, effect: null };
|
||||
case 'quit':
|
||||
return { next: state, effect: 'quit-without-save' };
|
||||
default:
|
||||
return { next: cloneState(state, { search: { ...state.search, query: state.search.query + cmd.value } }), effect: null };
|
||||
}
|
||||
}
|
||||
384
packages/cli/src/commands/connection-notion-tui.test.tsx
Normal file
384
packages/cli/src/commands/connection-notion-tui.test.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/* @jsxImportSource react */
|
||||
import { render as renderInkTest } from 'ink-testing-library';
|
||||
import React, { act, type ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './connection-notion-tree.js';
|
||||
import {
|
||||
NotionPickerApp,
|
||||
notionPickerCommandForInkInput,
|
||||
renderNotionPickerTui,
|
||||
resolveNotionPickerWidth,
|
||||
sanitizeNotionPickerTuiError,
|
||||
windowItems,
|
||||
windowOffset,
|
||||
type NotionPickerInkInstance,
|
||||
type NotionPickerInkRenderOptions,
|
||||
} from './connection-notion-tui.js';
|
||||
|
||||
const IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
marketing: '33333333-3333-3333-3333-333333333333',
|
||||
finance: '44444444-4444-4444-4444-444444444444',
|
||||
ops: '55555555-5555-5555-5555-555555555555',
|
||||
sales: '66666666-6666-6666-6666-666666666666',
|
||||
support: '77777777-7777-7777-7777-777777777777',
|
||||
product: '88888888-8888-8888-8888-888888888888',
|
||||
design: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function pages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function manyPages(): NotionPickerPageInput[] {
|
||||
return [
|
||||
{ id: IDS.engineering, title: 'Engineering Docs', archived: false, parentId: null },
|
||||
{ id: IDS.architecture, title: 'Architecture', archived: false, parentId: IDS.engineering },
|
||||
{ id: IDS.marketing, title: 'Marketing', archived: false, parentId: null },
|
||||
{ id: IDS.finance, title: 'Finance', archived: false, parentId: null },
|
||||
{ id: IDS.ops, title: 'Operations', archived: false, parentId: null },
|
||||
{ id: IDS.sales, title: 'Sales', archived: false, parentId: null },
|
||||
{ id: IDS.support, title: 'Support', archived: false, parentId: null },
|
||||
{ id: IDS.product, title: 'Product', archived: false, parentId: null },
|
||||
{ id: IDS.design, title: 'Design', archived: false, parentId: null },
|
||||
];
|
||||
}
|
||||
|
||||
function state(mode: 'all_accessible' | 'selected_roots' = 'selected_roots') {
|
||||
return buildInitialState({
|
||||
tree: buildPickerTree(pages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: mode,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForInkInput(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
function fakeInkInstance(): NotionPickerInkInstance {
|
||||
return {
|
||||
rerender: vi.fn(),
|
||||
unmount: vi.fn(),
|
||||
waitUntilExit: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrameWrap(frame: string | undefined): string {
|
||||
return frame?.replace(/\n/g, ' ') ?? '';
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('notionPickerCommandForInkInput', () => {
|
||||
it('maps browse, search, and confirm input to reducer commands', () => {
|
||||
expect(notionPickerCommandForInkInput('', { downArrow: true }, state().search, null)).toBe('cursor-down');
|
||||
expect(notionPickerCommandForInkInput('', { upArrow: true }, state().search, null)).toBe('cursor-up');
|
||||
expect(notionPickerCommandForInkInput('', { rightArrow: true }, state().search, null)).toBe('cursor-right');
|
||||
expect(notionPickerCommandForInkInput('', { leftArrow: true }, state().search, null)).toBe('cursor-left');
|
||||
expect(notionPickerCommandForInkInput(' ', {}, state().search, null)).toBe('toggle-check');
|
||||
expect(notionPickerCommandForInkInput('/', {}, state().search, null)).toBe('search-start');
|
||||
expect(notionPickerCommandForInkInput('a', {}, state().search, null)).toBe('select-all-visible');
|
||||
expect(notionPickerCommandForInkInput('n', {}, state().search, null)).toBe('select-none');
|
||||
expect(notionPickerCommandForInkInput('s', {}, state().search, null)).toBe('save-request');
|
||||
expect(notionPickerCommandForInkInput('q', {}, state().search, null)).toBe('quit');
|
||||
expect(notionPickerCommandForInkInput('c', { ctrl: true }, state().search, null)).toBe('quit');
|
||||
|
||||
expect(notionPickerCommandForInkInput('x', {}, { editing: true, query: '' }, null)).toEqual({
|
||||
type: 'search-input',
|
||||
value: 'x',
|
||||
});
|
||||
expect(notionPickerCommandForInkInput('', { backspace: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-backspace',
|
||||
);
|
||||
expect(notionPickerCommandForInkInput('', { return: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-submit',
|
||||
);
|
||||
expect(notionPickerCommandForInkInput('', { escape: true }, { editing: true, query: 'x' }, null)).toBe(
|
||||
'search-cancel',
|
||||
);
|
||||
|
||||
expect(notionPickerCommandForInkInput('y', {}, state().search, 'mode-switch')).toBe('save-confirm');
|
||||
expect(notionPickerCommandForInkInput('', { return: true }, state().search, 'mode-switch')).toBe('save-confirm');
|
||||
expect(notionPickerCommandForInkInput('n', {}, state().search, 'mode-switch')).toBe('save-cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('window helpers', () => {
|
||||
it('centers the selected row and returns the visible slice', () => {
|
||||
expect(windowOffset(20, 10, 5)).toBe(8);
|
||||
expect(windowItems(['a', 'b', 'c', 'd', 'e'], 3, 3)).toEqual({ items: ['c', 'd', 'e'], offset: 2 });
|
||||
});
|
||||
|
||||
it('clamps picker width to the design rule', () => {
|
||||
expect(resolveNotionPickerWidth(200)).toBe(120);
|
||||
expect(resolveNotionPickerWidth(100)).toBe(96);
|
||||
expect(resolveNotionPickerWidth(50)).toBe(60);
|
||||
expect(resolveNotionPickerWidth(undefined)).toBe(96);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotionPickerApp', () => {
|
||||
it('renders spec banners, row glyphs, search visibility, and hint text', () => {
|
||||
const initialState = {
|
||||
...state('all_accessible'),
|
||||
preLoadWarnings: ['1 stored root_page_ids no longer visible'],
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={5000}
|
||||
currentCrawlMode="all_accessible"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Notion pages visible to integration "Design Workspace"');
|
||||
expect(frame).toContain('5000-page cap reached - some pages not shown');
|
||||
expect(frame).toContain('1 stored root_page_ids no longer visible - they will be removed if you save');
|
||||
expect(frame).toContain('▸ [ ] Engineering Docs ▸ (1)');
|
||||
expect(frame).toContain(' [ ] Marketing');
|
||||
expect(frame).not.toContain('Search ready: -');
|
||||
expect(frame).toContain('space toggle · enter expand · / search · a all · n none · s save & exit · q quit');
|
||||
});
|
||||
|
||||
it('renders partial discovery warnings without stale-root save suffix', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
preLoadWarnings: ['Notion search stopped early: rate limit after first page'],
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('Notion search stopped early: rate limit after first page');
|
||||
expect(frame).not.toContain(
|
||||
'Notion search stopped early: rate limit after first page - they will be removed if you save',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders checked parents and locked descendants with the locked design glyphs', () => {
|
||||
const initialState = {
|
||||
...state(),
|
||||
checked: new Set([IDS.engineering]),
|
||||
expanded: new Set([IDS.engineering]),
|
||||
};
|
||||
const { lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('▸ [×] Engineering Docs ▾');
|
||||
expect(frame).toContain(' [~] Architecture');
|
||||
});
|
||||
|
||||
it('supports keyboard selection, all_accessible confirmation, and save callback', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state('all_accessible')}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="all_accessible"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write(' ');
|
||||
await waitForInkInput();
|
||||
expect(lastFrame()).toContain('[×] Engineering Docs');
|
||||
|
||||
stdin.write('s');
|
||||
await waitForInkInput();
|
||||
expect(normalizeFrameWrap(lastFrame())).toContain(
|
||||
'Save will switch crawl_mode all_accessible -> selected_roots and limit ingest to 1 selected page. [y] confirm [esc] back',
|
||||
);
|
||||
|
||||
stdin.write('y');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'save', rootPageIds: [IDS.engineering] });
|
||||
});
|
||||
|
||||
it('removes transient hints after their expiry time', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state()}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('s');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
expect(lastFrame()).toContain('Select at least one page or press q to quit');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
});
|
||||
expect(lastFrame()).not.toContain('Select at least one page or press q to quit');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders row-window overflow indicators when the visible list is clipped', async () => {
|
||||
const onExit = vi.fn();
|
||||
const initialState = buildInitialState({
|
||||
tree: buildPickerTree(manyPages()),
|
||||
existingRootPageIds: [],
|
||||
currentCrawlMode: 'selected_roots',
|
||||
});
|
||||
initialState.expanded = new Set([IDS.engineering]);
|
||||
const { stdin, lastFrame } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={initialState}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={13}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('↓ 4 more');
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
stdin.write('\u001B[B');
|
||||
await waitForInkInput();
|
||||
|
||||
const frame = lastFrame() ?? '';
|
||||
expect(frame).toContain('↑ ');
|
||||
expect(frame).toContain('↓ ');
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns quit without saving', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin } = renderInkTest(
|
||||
<NotionPickerApp
|
||||
initialState={state()}
|
||||
connectionId="notion-main"
|
||||
workspaceLabel="Design Workspace"
|
||||
cappedAtCount={null}
|
||||
currentCrawlMode="selected_roots"
|
||||
terminalRows={24}
|
||||
terminalWidth={100}
|
||||
onExit={onExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('q');
|
||||
await waitForInkInput();
|
||||
expect(onExit).toHaveBeenCalledWith({ kind: 'quit' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderNotionPickerTui', () => {
|
||||
it('returns the app result from the Ink runtime', async () => {
|
||||
const io = {
|
||||
stdin: { isTTY: true, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: true, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
const renderInk = vi.fn((_tree: ReactNode, _options: NotionPickerInkRenderOptions) => fakeInkInstance());
|
||||
|
||||
await expect(
|
||||
renderNotionPickerTui(
|
||||
{
|
||||
initialState: state(),
|
||||
connectionId: 'notion-main',
|
||||
workspaceLabel: 'Design Workspace',
|
||||
cappedAtCount: null,
|
||||
currentCrawlMode: 'selected_roots',
|
||||
},
|
||||
io,
|
||||
{ renderInk },
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(renderInk).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sanitizes render errors and tells the user to use no-input mode', async () => {
|
||||
expect(sanitizeNotionPickerTuiError(new Error('token=secret https://api.notion.com/v1/search'))).toBe(
|
||||
'[redacted] [redacted-url]',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to quit with a scripted-mode hint when Ink cannot initialize', async () => {
|
||||
let stderr = '';
|
||||
const io = {
|
||||
stdin: { isTTY: false, setRawMode: vi.fn() },
|
||||
stdout: { isTTY: false, columns: 100, rows: 24, write: vi.fn() },
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
renderNotionPickerTui(
|
||||
{
|
||||
initialState: state(),
|
||||
connectionId: 'notion-main',
|
||||
workspaceLabel: 'Design Workspace',
|
||||
cappedAtCount: null,
|
||||
currentCrawlMode: 'selected_roots',
|
||||
},
|
||||
io,
|
||||
{
|
||||
renderInk: vi.fn(() => {
|
||||
throw new Error('token=secret');
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ kind: 'quit' });
|
||||
expect(stderr).toContain('Use --no-input --root-page-id <UUID> for scripted mode');
|
||||
expect(stderr).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
338
packages/cli/src/commands/connection-notion-tui.tsx
Normal file
338
packages/cli/src/commands/connection-notion-tui.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/* @jsxImportSource react */
|
||||
import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink';
|
||||
import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
filterTree,
|
||||
flattenSelection,
|
||||
isAncestorChecked,
|
||||
reducer,
|
||||
visibleNodeIds,
|
||||
type PickerCommand,
|
||||
type PickerState,
|
||||
} from './connection-notion-tree.js';
|
||||
import type { KloCliIo } from '../index.js';
|
||||
|
||||
const COLOR_THEME = {
|
||||
text: 'white',
|
||||
muted: 'gray',
|
||||
active: 'cyan',
|
||||
warning: 'yellow',
|
||||
} as const;
|
||||
|
||||
const NO_COLOR_THEME = {
|
||||
text: 'white',
|
||||
muted: 'white',
|
||||
active: 'white',
|
||||
warning: 'white',
|
||||
} as const;
|
||||
|
||||
type NotionPickerTheme = Record<keyof typeof COLOR_THEME, string>;
|
||||
|
||||
export interface NotionPickerTuiIo extends KloCliIo {
|
||||
stdin?: { isTTY?: boolean; setRawMode?(value: boolean): void };
|
||||
stdout: KloCliIo['stdout'] & { isTTY?: boolean; columns?: number; rows?: number };
|
||||
}
|
||||
|
||||
interface InkKey {
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
return?: boolean;
|
||||
escape?: boolean;
|
||||
ctrl?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
export type PickerRenderResult = { kind: 'save'; rootPageIds: string[] } | { kind: 'quit' };
|
||||
|
||||
export interface PickerRenderInput {
|
||||
initialState: PickerState;
|
||||
connectionId: string;
|
||||
workspaceLabel: string;
|
||||
cappedAtCount: number | null;
|
||||
currentCrawlMode: 'all_accessible' | 'selected_roots';
|
||||
}
|
||||
|
||||
interface NotionPickerAppProps extends PickerRenderInput {
|
||||
terminalRows?: number;
|
||||
terminalWidth?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onExit(result: PickerRenderResult): void;
|
||||
}
|
||||
|
||||
export interface NotionPickerInkInstance {
|
||||
rerender(tree: ReactNode): void;
|
||||
unmount(): void;
|
||||
waitUntilExit(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface NotionPickerInkRenderOptions {
|
||||
stdin?: NotionPickerTuiIo['stdin'];
|
||||
stdout: NotionPickerTuiIo['stdout'];
|
||||
stderr: NotionPickerTuiIo['stderr'];
|
||||
exitOnCtrlC: boolean;
|
||||
patchConsole: boolean;
|
||||
maxFps: number;
|
||||
alternateScreen: boolean;
|
||||
}
|
||||
|
||||
function resolveTheme(env: NodeJS.ProcessEnv = process.env): NotionPickerTheme {
|
||||
return env.NO_COLOR || env.TERM === 'dumb' ? NO_COLOR_THEME : COLOR_THEME;
|
||||
}
|
||||
|
||||
export function resolveNotionPickerWidth(columns: number | undefined): number {
|
||||
const resolvedColumns = columns ?? 100;
|
||||
return Math.max(60, Math.min(120, resolvedColumns - 4));
|
||||
}
|
||||
|
||||
function staleWarningText(warning: string): string {
|
||||
return warning.includes('stored root_page_ids no longer visible')
|
||||
? `${warning} - they will be removed if you save`
|
||||
: warning;
|
||||
}
|
||||
|
||||
function selectedPageCountText(count: number): string {
|
||||
return `${count} selected ${count === 1 ? 'page' : 'pages'}`;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(state: PickerState, nodeId: string): boolean {
|
||||
const query = state.search.query.trim().toLocaleLowerCase();
|
||||
if (!query) {
|
||||
return false;
|
||||
}
|
||||
const node = state.byId.get(nodeId);
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
return node.title.toLocaleLowerCase().includes(query) || node.path.toLocaleLowerCase().includes(query);
|
||||
}
|
||||
|
||||
export function sanitizeNotionPickerTuiError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message
|
||||
.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted-url]')
|
||||
.replace(/\b(api[_-]?key|password|token|secret)=\S+/gi, '[redacted]');
|
||||
}
|
||||
|
||||
export function windowOffset(count: number, selected: number, visible: number): number {
|
||||
if (count <= visible) return 0;
|
||||
return Math.max(0, Math.min(count - visible, selected - Math.floor(visible / 2)));
|
||||
}
|
||||
|
||||
export function windowItems<T>(items: T[], selected: number, visible: number): { items: T[]; offset: number } {
|
||||
const offset = windowOffset(items.length, selected, visible);
|
||||
return { items: items.slice(offset, offset + visible), offset };
|
||||
}
|
||||
|
||||
function truncateText(value: string, width: number): string {
|
||||
if (value.length <= width) return value;
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return `${value.slice(0, width - 3)}...`;
|
||||
}
|
||||
|
||||
export function notionPickerCommandForInkInput(
|
||||
input: string,
|
||||
key: InkKey,
|
||||
search: PickerState['search'],
|
||||
pendingConfirm: PickerState['pendingConfirm'],
|
||||
): PickerCommand | null {
|
||||
if (pendingConfirm) {
|
||||
if (input === 'y' || key.return) return 'save-confirm';
|
||||
if (input === 'n' || key.escape) return 'save-cancel';
|
||||
if (key.ctrl === true && input === 'c') return 'quit';
|
||||
return null;
|
||||
}
|
||||
if (search.editing) {
|
||||
if (key.escape) return 'search-cancel';
|
||||
if (key.return) return 'search-submit';
|
||||
if (key.backspace || key.delete) return 'search-backspace';
|
||||
if (key.downArrow) return 'cursor-down';
|
||||
if (key.upArrow) return 'cursor-up';
|
||||
if (input.length === 1 && input >= ' ' && input !== '\u007f') return { type: 'search-input', value: input };
|
||||
return null;
|
||||
}
|
||||
if (key.ctrl === true && input === 'c') return 'quit';
|
||||
if (key.upArrow) return 'cursor-up';
|
||||
if (key.downArrow) return 'cursor-down';
|
||||
if (key.leftArrow) return 'cursor-left';
|
||||
if (key.rightArrow) return 'cursor-right';
|
||||
if (key.return) return 'expand';
|
||||
if (input === ' ') return 'toggle-check';
|
||||
if (input === '/') return 'search-start';
|
||||
if (input === 'a') return 'select-all-visible';
|
||||
if (input === 'n') return 'select-none';
|
||||
if (input === 's') return 'save-request';
|
||||
if (input === 'q' || key.escape) return 'quit';
|
||||
return null;
|
||||
}
|
||||
|
||||
function PickerRow(props: { state: PickerState; nodeId: string; width: number; theme: NotionPickerTheme }): ReactNode {
|
||||
const node = props.state.byId.get(props.nodeId);
|
||||
if (!node) return null;
|
||||
const focused = props.state.cursorId === node.id;
|
||||
const locked = isAncestorChecked(node.id, props.state.checked, props.state.byId);
|
||||
const checked = props.state.checked.has(node.id);
|
||||
const glyph = locked ? '[~]' : checked ? '[×]' : '[ ]';
|
||||
const children =
|
||||
node.childIds.length > 0 ? (props.state.expanded.has(node.id) ? ' ▾' : ` ▸ (${node.childIds.length})`) : '';
|
||||
const prefix = `${focused ? '▸' : ' '} ${glyph} ${' '.repeat(node.depth * 2)}`;
|
||||
const color = focused ? props.theme.active : locked || node.archived ? props.theme.muted : props.theme.text;
|
||||
const title = truncateText(`${node.title}${children}`, Math.max(10, props.width - prefix.length));
|
||||
const inverse = rowMatchesSearch(props.state, node.id);
|
||||
|
||||
return (
|
||||
<Text color={color} strikethrough={node.archived}>
|
||||
{prefix}
|
||||
<Text inverse={inverse}>{title}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotionPickerApp(props: NotionPickerAppProps): ReactNode {
|
||||
const app = useApp();
|
||||
const [state, setState] = useState(props.initialState);
|
||||
const stateRef = useRef(state);
|
||||
const theme = useMemo(() => resolveTheme(props.env), [props.env]);
|
||||
const visibleIds = visibleNodeIds(state);
|
||||
const selectedIndex = Math.max(0, visibleIds.indexOf(state.cursorId));
|
||||
const reservedRows = state.pendingConfirm === 'mode-switch' ? 9 : 8;
|
||||
const visibleRows = Math.max(5, Math.min(20, (props.terminalRows ?? 24) - reservedRows));
|
||||
const rows = windowItems(visibleIds, selectedIndex, visibleRows);
|
||||
const hiddenAbove = rows.offset;
|
||||
const hiddenBelow = Math.max(0, visibleIds.length - rows.offset - rows.items.length);
|
||||
const searchMatchCount = filterTree(state).visibleIds.size;
|
||||
const width = resolveNotionPickerWidth(props.terminalWidth);
|
||||
const showSearch = state.search.editing || state.search.query.trim().length > 0;
|
||||
const selectedCount = flattenSelection(state.checked, state.byId).length;
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
useEffect(() => {
|
||||
const hint = state.transientHint;
|
||||
if (!hint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearHint = () => {
|
||||
setState((current) => {
|
||||
const { next } = reducer(current, 'clear-transient-hint');
|
||||
stateRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const delay = hint.expiresAt - Date.now();
|
||||
if (delay <= 0) {
|
||||
clearHint();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(clearHint, delay);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [state.transientHint?.expiresAt]);
|
||||
|
||||
useInput((input, key) => {
|
||||
const command = notionPickerCommandForInkInput(input, key, stateRef.current.search, stateRef.current.pendingConfirm);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const { next, effect } = reducer(stateRef.current, command);
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
if (effect === 'save') {
|
||||
props.onExit({ kind: 'save', rootPageIds: flattenSelection(next.checked, next.byId) });
|
||||
app.exit();
|
||||
return;
|
||||
}
|
||||
if (effect === 'quit-without-save') {
|
||||
props.onExit({ kind: 'quit' });
|
||||
app.exit();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.active}>Notion pages visible to integration "{props.workspaceLabel}"</Text>
|
||||
{props.cappedAtCount ? <Text color={theme.warning}>{props.cappedAtCount}-page cap reached - some pages not shown</Text> : null}
|
||||
{state.preLoadWarnings.map((warning) => (
|
||||
<Text key={warning} color={theme.warning}>
|
||||
{staleWarningText(warning)}
|
||||
</Text>
|
||||
))}
|
||||
{showSearch ? (
|
||||
<Text color={theme.muted}>
|
||||
/ {state.search.query}
|
||||
{state.search.editing ? '█' : ''} ({searchMatchCount} matches)
|
||||
</Text>
|
||||
) : null}
|
||||
<Box flexDirection="column">
|
||||
{hiddenAbove > 0 ? <Text color={theme.muted}>↑ {hiddenAbove} more</Text> : null}
|
||||
{rows.items.map((nodeId) => (
|
||||
<PickerRow key={nodeId} state={state} nodeId={nodeId} width={width} theme={theme} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text color={theme.muted}>↓ {hiddenBelow} more</Text> : null}
|
||||
</Box>
|
||||
{state.pendingConfirm === 'mode-switch' ? (
|
||||
<Text color={theme.warning}>
|
||||
Save will switch crawl_mode all_accessible -> selected_roots and limit ingest to{' '}
|
||||
{selectedPageCountText(selectedCount)}. [y] confirm [esc] back
|
||||
</Text>
|
||||
) : null}
|
||||
{state.transientHint ? <Text color={theme.warning}>{state.transientHint.text}</Text> : null}
|
||||
<Text color={theme.muted}>space toggle · enter expand · / search · a all · n none · s save & exit · q quit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInk(tree: ReactNode, options: NotionPickerInkRenderOptions): NotionPickerInkInstance {
|
||||
return renderInkRuntime(tree, {
|
||||
stdin: options.stdin as NodeJS.ReadStream | undefined,
|
||||
stdout: options.stdout as NodeJS.WriteStream,
|
||||
stderr: options.stderr as NodeJS.WriteStream,
|
||||
exitOnCtrlC: options.exitOnCtrlC,
|
||||
patchConsole: options.patchConsole,
|
||||
maxFps: options.maxFps,
|
||||
alternateScreen: options.alternateScreen,
|
||||
}) as NotionPickerInkInstance;
|
||||
}
|
||||
|
||||
export async function renderNotionPickerTui(
|
||||
input: PickerRenderInput,
|
||||
io: NotionPickerTuiIo,
|
||||
options: { renderInk?: (tree: ReactNode, options: NotionPickerInkRenderOptions) => NotionPickerInkInstance } = {},
|
||||
): Promise<PickerRenderResult> {
|
||||
let result: PickerRenderResult = { kind: 'quit' };
|
||||
let instance: NotionPickerInkInstance | null = null;
|
||||
try {
|
||||
instance = (options.renderInk ?? renderInk)(
|
||||
<NotionPickerApp
|
||||
{...input}
|
||||
terminalRows={(io.stdout as { rows?: number }).rows ?? process.stdout.rows ?? 24}
|
||||
terminalWidth={io.stdout.columns ?? process.stdout.columns}
|
||||
onExit={(next) => {
|
||||
result = next;
|
||||
instance?.unmount();
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
stdin: io.stdin,
|
||||
stdout: io.stdout,
|
||||
stderr: io.stderr,
|
||||
exitOnCtrlC: false,
|
||||
patchConsole: false,
|
||||
maxFps: 30,
|
||||
alternateScreen: true,
|
||||
},
|
||||
);
|
||||
await instance.waitUntilExit();
|
||||
instance.unmount();
|
||||
return result;
|
||||
} catch (error) {
|
||||
io.stderr.write(
|
||||
`Notion picker requires a TTY. Use --no-input --root-page-id <UUID> for scripted mode. ${sanitizeNotionPickerTuiError(error)}\n`,
|
||||
);
|
||||
return { kind: 'quit' };
|
||||
}
|
||||
}
|
||||
466
packages/cli/src/commands/connection-notion.test.ts
Normal file
466
packages/cli/src/commands/connection-notion.test.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
initKloProject,
|
||||
loadKloProject,
|
||||
serializeKloProjectConfig,
|
||||
type KloProjectConfig,
|
||||
} from '@klo/context/project';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
applyNotionPickerWriteback,
|
||||
discoverNotionPickerPages,
|
||||
notionPickerPageFromSearchResult,
|
||||
normalizeNotionPageId,
|
||||
resolveNotionWorkspaceLabel,
|
||||
runKloConnectionNotion,
|
||||
type NotionPickerApi,
|
||||
type PickerRenderInput,
|
||||
type PickerRenderResult,
|
||||
} from './connection-notion.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
type FakeNotionSearchPage = Record<string, unknown> & { id: string; object: 'page' };
|
||||
|
||||
const PAGE_IDS = {
|
||||
engineering: '11111111-1111-1111-1111-111111111111',
|
||||
architecture: '22222222-2222-2222-2222-222222222222',
|
||||
stale: '99999999-9999-9999-9999-999999999999',
|
||||
};
|
||||
|
||||
function notionPage(id: string, title: string, parentId: string | null = null): FakeNotionSearchPage {
|
||||
return {
|
||||
object: 'page',
|
||||
id,
|
||||
archived: false,
|
||||
parent: parentId ? { type: 'page_id', page_id: parentId } : { type: 'workspace', workspace: true },
|
||||
properties: {
|
||||
title: {
|
||||
type: 'title',
|
||||
title: [{ plain_text: title }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeNotionApi(pages: FakeNotionSearchPage[]): NotionPickerApi {
|
||||
return {
|
||||
search: vi.fn(async (_filterValue, startCursor) => {
|
||||
if (startCursor === 'page-2') {
|
||||
return { results: pages.slice(2), hasMore: false, nextCursor: null };
|
||||
}
|
||||
return {
|
||||
results: pages.slice(0, 2),
|
||||
hasMore: pages.length > 2,
|
||||
nextCursor: pages.length > 2 ? 'page-2' : null,
|
||||
};
|
||||
}),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot', bot: { workspace_name: 'Design Workspace' } })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeNotionPageId', () => {
|
||||
it('accepts dashed and compact UUIDs', () => {
|
||||
expect(normalizeNotionPageId('11111111222233334444555555555555')).toBe(
|
||||
'11111111-2222-3333-4444-555555555555',
|
||||
);
|
||||
expect(normalizeNotionPageId('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')).toBe(
|
||||
'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runKloConnectionNotion', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-cli-notion-pick-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeProjectConfig(projectDir: string, config: KloProjectConfig): Promise<void> {
|
||||
const project = await loadKloProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(config),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
'seed test config',
|
||||
);
|
||||
}
|
||||
|
||||
it('rejects unsafe connection ids before loading a project', async () => {
|
||||
const io = makeIo();
|
||||
const loadProject = vi.fn(async () => {
|
||||
throw new Error('loadProject should not be called');
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir: '/tmp/project',
|
||||
connectionId: '../evil',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{ loadProject },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(loadProject).not.toHaveBeenCalled();
|
||||
expect(io.stderr()).toContain('Unsafe connection id: ../evil');
|
||||
});
|
||||
|
||||
it('writes selected root_page_ids while preserving every other Notion connection field', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'all_accessible',
|
||||
root_page_ids: ['99999999-9999-9999-9999-999999999999'],
|
||||
root_database_ids: ['database-1'],
|
||||
root_data_source_ids: ['data-source-1'],
|
||||
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"}',
|
||||
unknown_future_field: 'keep-me',
|
||||
},
|
||||
},
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'non-interactive',
|
||||
rootPageIds: [
|
||||
'11111111-2222-3333-4444-555555555555',
|
||||
'66666666-7777-8888-9999-aaaaaaaaaaaa',
|
||||
],
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const yaml = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(yaml).toContain('crawl_mode: selected_roots');
|
||||
expect(yaml).toContain('root_page_ids:');
|
||||
expect(yaml).toContain('11111111-2222-3333-4444-555555555555');
|
||||
expect(yaml).toContain('66666666-7777-8888-9999-aaaaaaaaaaaa');
|
||||
expect(yaml).toContain('root_database_ids:');
|
||||
expect(yaml).toContain('database-1');
|
||||
expect(yaml).toContain('root_data_source_ids:');
|
||||
expect(yaml).toContain('data-source-1');
|
||||
expect(yaml).toContain('last_successful_cursor: \'{"phase":"all_accessible_pages","cursor":"cursor-1"}\'');
|
||||
expect(yaml).toContain('unknown_future_field: keep-me');
|
||||
expect(io.stdout()).toContain('Connection: notion-main');
|
||||
expect(io.stdout()).toContain('rootPageIds: 2');
|
||||
expect(io.stdout()).toContain('crawlMode: selected_roots');
|
||||
});
|
||||
|
||||
it('rejects empty writeback, missing connections, and non-Notion connections', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
warehouse: {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const project = await loadKloProject({ projectDir });
|
||||
|
||||
await expect(applyNotionPickerWriteback(project, 'warehouse', [])).rejects.toThrow(
|
||||
'connection notion pick requires at least one root page id',
|
||||
);
|
||||
await expect(
|
||||
applyNotionPickerWriteback(project, 'missing', ['11111111-2222-3333-4444-555555555555']),
|
||||
).rejects.toThrow('Connection "missing" not found');
|
||||
await expect(
|
||||
applyNotionPickerWriteback(project, 'warehouse', ['11111111-2222-3333-4444-555555555555']),
|
||||
).rejects.toThrow('Connection "warehouse" is not a Notion connection');
|
||||
});
|
||||
|
||||
it('extracts picker page inputs from Notion search results', () => {
|
||||
expect(notionPickerPageFromSearchResult(notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering)))
|
||||
.toEqual({
|
||||
id: PAGE_IDS.architecture,
|
||||
title: 'Architecture',
|
||||
archived: false,
|
||||
parentId: PAGE_IDS.engineering,
|
||||
});
|
||||
|
||||
expect(
|
||||
notionPickerPageFromSearchResult({
|
||||
object: 'page',
|
||||
id: PAGE_IDS.engineering.replaceAll('-', ''),
|
||||
archived: true,
|
||||
parent: { type: 'workspace', workspace: true },
|
||||
properties: {},
|
||||
}),
|
||||
).toEqual({
|
||||
id: PAGE_IDS.engineering,
|
||||
title: 'Untitled',
|
||||
archived: true,
|
||||
parentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('discovers visible pages up to the cap and reports cap state', async () => {
|
||||
const api = fakeNotionApi([
|
||||
notionPage(PAGE_IDS.engineering, 'Engineering'),
|
||||
notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering),
|
||||
notionPage('33333333-3333-3333-3333-333333333333', 'Onboarding', PAGE_IDS.engineering),
|
||||
]);
|
||||
|
||||
await expect(discoverNotionPickerPages(api, { cap: 2 })).resolves.toEqual({
|
||||
pages: [
|
||||
{ id: PAGE_IDS.engineering, title: 'Engineering', archived: false, parentId: null },
|
||||
{ id: PAGE_IDS.architecture, title: 'Architecture', archived: false, parentId: PAGE_IDS.engineering },
|
||||
],
|
||||
cappedAtCount: 2,
|
||||
warnings: [],
|
||||
});
|
||||
expect(api.search).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps partial discovery results when Notion search fails after at least one page', async () => {
|
||||
const api: NotionPickerApi = {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
results: [notionPage(PAGE_IDS.engineering, 'Engineering')],
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-2',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('rate limit after first page')),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot' })),
|
||||
};
|
||||
|
||||
await expect(discoverNotionPickerPages(api)).resolves.toEqual({
|
||||
pages: [{ id: PAGE_IDS.engineering, title: 'Engineering', archived: false, parentId: null }],
|
||||
cappedAtCount: null,
|
||||
warnings: ['Notion search stopped early: rate limit after first page'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the Notion workspace name when available and falls back to the connection id', async () => {
|
||||
await expect(resolveNotionWorkspaceLabel(fakeNotionApi([]), 'notion-main')).resolves.toBe('Design Workspace');
|
||||
await expect(
|
||||
resolveNotionWorkspaceLabel(
|
||||
{
|
||||
search: vi.fn(),
|
||||
retrieveBotUser: vi.fn(async () => {
|
||||
throw new Error('users.me unavailable');
|
||||
}),
|
||||
},
|
||||
'notion-main',
|
||||
),
|
||||
).resolves.toBe('notion-main');
|
||||
});
|
||||
|
||||
it('runs interactive discovery, warns about stale roots, renders the TUI, and saves selected roots', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'all_accessible',
|
||||
root_page_ids: [PAGE_IDS.stale],
|
||||
root_database_ids: ['database-1'],
|
||||
root_data_source_ids: ['data-source-1'],
|
||||
max_pages_per_run: 12,
|
||||
max_knowledge_creates_per_run: 2,
|
||||
max_knowledge_updates_per_run: 7,
|
||||
last_successful_cursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const api = fakeNotionApi([
|
||||
notionPage(PAGE_IDS.engineering, 'Engineering'),
|
||||
notionPage(PAGE_IDS.architecture, 'Architecture', PAGE_IDS.engineering),
|
||||
]);
|
||||
const renderPicker = vi.fn(async (input): Promise<PickerRenderResult> => {
|
||||
expect(input.connectionId).toBe('notion-main');
|
||||
expect(input.workspaceLabel).toBe('Design Workspace');
|
||||
expect(input.currentCrawlMode).toBe('all_accessible');
|
||||
expect(input.cappedAtCount).toBeNull();
|
||||
expect(input.initialState.preLoadWarnings).toEqual(['1 stored root_page_ids no longer visible']);
|
||||
return { kind: 'save', rootPageIds: [PAGE_IDS.engineering] };
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: { NOTION_TOKEN: 'ntn_test_token' },
|
||||
createNotionApi: vi.fn(() => api),
|
||||
renderPicker,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const yaml = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(yaml).toContain('crawl_mode: selected_roots');
|
||||
expect(yaml).toContain(PAGE_IDS.engineering);
|
||||
expect(yaml).not.toContain(PAGE_IDS.stale);
|
||||
expect(io.stderr()).toContain('1 stored root_page_ids no longer visible');
|
||||
expect(io.stdout()).toContain('Connection: notion-main');
|
||||
expect(io.stdout()).toContain('rootPageIds: 1');
|
||||
});
|
||||
|
||||
it('passes partial-discovery warnings into the TUI banner state', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: [PAGE_IDS.engineering],
|
||||
root_database_ids: [],
|
||||
root_data_source_ids: [],
|
||||
max_pages_per_run: 12,
|
||||
max_knowledge_creates_per_run: 2,
|
||||
max_knowledge_updates_per_run: 7,
|
||||
last_successful_cursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const api: NotionPickerApi = {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
results: [notionPage(PAGE_IDS.engineering, 'Engineering')],
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-2',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('rate limit after first page')),
|
||||
retrieveBotUser: vi.fn(async () => ({ name: 'Notion bot', bot: { workspace_name: 'Design Workspace' } })),
|
||||
};
|
||||
let renderInput: PickerRenderInput | undefined;
|
||||
const renderPicker = vi.fn(async (input: PickerRenderInput): Promise<PickerRenderResult> => {
|
||||
renderInput = input;
|
||||
return { kind: 'quit' };
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: { NOTION_TOKEN: 'ntn_test_token' },
|
||||
createNotionApi: vi.fn(() => api),
|
||||
renderPicker,
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(renderPicker).toHaveBeenCalledOnce();
|
||||
if (!renderInput) {
|
||||
throw new Error('renderPicker was not called');
|
||||
}
|
||||
expect(renderInput.initialState.preLoadWarnings).toEqual(['Notion search stopped early: rate limit after first page']);
|
||||
expect(renderInput.initialState.tree.map((node) => node.title)).toEqual(['Engineering']);
|
||||
expect(io.stderr()).toContain('Notion search stopped early: rate limit after first page');
|
||||
expect(io.stdout()).toContain('No changes saved.');
|
||||
});
|
||||
|
||||
it('quits interactive mode without writing when the TUI returns quit', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const initialized = await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeProjectConfig(projectDir, {
|
||||
...initialized.config,
|
||||
connections: {
|
||||
'notion-main': {
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: [PAGE_IDS.engineering],
|
||||
root_database_ids: [],
|
||||
root_data_source_ids: [],
|
||||
max_pages_per_run: 12,
|
||||
max_knowledge_creates_per_run: 2,
|
||||
max_knowledge_updates_per_run: 7,
|
||||
last_successful_cursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const before = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnectionNotion(
|
||||
{
|
||||
command: 'pick',
|
||||
projectDir,
|
||||
connectionId: 'notion-main',
|
||||
mode: 'interactive',
|
||||
},
|
||||
io.io,
|
||||
{
|
||||
env: { NOTION_TOKEN: 'ntn_test_token' },
|
||||
createNotionApi: vi.fn(() => fakeNotionApi([notionPage(PAGE_IDS.engineering, 'Engineering')])),
|
||||
renderPicker: vi.fn(async (): Promise<PickerRenderResult> => ({ kind: 'quit' })),
|
||||
},
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await expect(readFile(join(projectDir, 'klo.yaml'), 'utf-8')).resolves.toBe(before);
|
||||
expect(io.stdout()).toContain('No changes saved.');
|
||||
});
|
||||
});
|
||||
278
packages/cli/src/commands/connection-notion.ts
Normal file
278
packages/cli/src/commands/connection-notion.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { parseNotionConnectionConfig, resolveNotionAuthToken } from '@klo/context/connections';
|
||||
import { type NotionApi, type NotionBotInfo, NotionClient } from '@klo/context/ingest';
|
||||
import {
|
||||
type KloLocalProject,
|
||||
type KloProjectConnectionConfig,
|
||||
loadKloProject,
|
||||
serializeKloProjectConfig,
|
||||
} from '@klo/context/project';
|
||||
import type { KloCliIo } from '../index.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
import { buildInitialState, buildPickerTree, type NotionPickerPageInput } from './connection-notion-tree.js';
|
||||
import {
|
||||
type NotionPickerTuiIo,
|
||||
type PickerRenderInput,
|
||||
type PickerRenderResult,
|
||||
renderNotionPickerTui,
|
||||
} from './connection-notion-tui.js';
|
||||
|
||||
profileMark('module:commands/connection-notion');
|
||||
|
||||
export type KloConnectionNotionArgs =
|
||||
| {
|
||||
command: 'pick';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: 'interactive';
|
||||
}
|
||||
| {
|
||||
command: 'pick';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
mode: 'non-interactive';
|
||||
rootPageIds: string[];
|
||||
};
|
||||
|
||||
export type NotionPickerApi = Pick<NotionApi, 'search' | 'retrieveBotUser'>;
|
||||
export type { PickerRenderInput, PickerRenderResult };
|
||||
|
||||
interface KloConnectionNotionDeps {
|
||||
env?: Record<string, string | undefined>;
|
||||
loadProject?: typeof loadKloProject;
|
||||
createNotionApi?: (authToken: string) => NotionPickerApi;
|
||||
renderPicker?: (input: PickerRenderInput, io: NotionPickerTuiIo) => Promise<PickerRenderResult>;
|
||||
}
|
||||
|
||||
const NOTION_PICKER_PAGE_CAP = 5000;
|
||||
|
||||
function assertSafeConnectionId(connectionId: string): void {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
|
||||
throw new Error(`Unsafe connection id: ${connectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNotionPageId(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const compact = trimmed.includes('-') ? trimmed.replace(/-/g, '') : trimmed;
|
||||
if (!/^[0-9a-fA-F]{32}$/.test(compact)) {
|
||||
throw new Error(`Invalid Notion page UUID: ${value}`);
|
||||
}
|
||||
const lower = compact.toLowerCase();
|
||||
return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`;
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractTitleFromNotionPage(page: Record<string, unknown>): string {
|
||||
const properties = recordValue(page.properties);
|
||||
if (!properties) {
|
||||
return 'Untitled';
|
||||
}
|
||||
for (const property of Object.values(properties)) {
|
||||
const value = recordValue(property);
|
||||
if (!value || value.type !== 'title' || !Array.isArray(value.title)) {
|
||||
continue;
|
||||
}
|
||||
const text = value.title
|
||||
.map((part) => {
|
||||
const richText = recordValue(part);
|
||||
return typeof richText?.plain_text === 'string' ? richText.plain_text : '';
|
||||
})
|
||||
.join('')
|
||||
.trim();
|
||||
if (text.length > 0) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return 'Untitled';
|
||||
}
|
||||
|
||||
function extractParentPageId(page: Record<string, unknown>): string | null {
|
||||
const parent = recordValue(page.parent);
|
||||
if (!parent || parent.type !== 'page_id' || typeof parent.page_id !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return normalizeNotionPageId(parent.page_id);
|
||||
}
|
||||
|
||||
export function notionPickerPageFromSearchResult(result: Record<string, unknown>): NotionPickerPageInput {
|
||||
const id = typeof result.id === 'string' ? normalizeNotionPageId(result.id) : '';
|
||||
if (!id) {
|
||||
throw new Error('Notion page search result is missing id');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: extractTitleFromNotionPage(result),
|
||||
archived: result.archived === true,
|
||||
parentId: extractParentPageId(result),
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverNotionPickerPages(
|
||||
api: NotionPickerApi,
|
||||
options: { cap?: number } = {},
|
||||
): Promise<{ pages: NotionPickerPageInput[]; cappedAtCount: number | null; warnings: string[] }> {
|
||||
const cap = options.cap ?? NOTION_PICKER_PAGE_CAP;
|
||||
const pages: NotionPickerPageInput[] = [];
|
||||
const warnings: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
|
||||
while (pages.length < cap) {
|
||||
let response: Awaited<ReturnType<NotionPickerApi['search']>>;
|
||||
try {
|
||||
response = await api.search('page', cursor, Math.min(100, cap - pages.length));
|
||||
} catch (error) {
|
||||
if (pages.length === 0) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
warnings.push(`Notion search stopped early: ${message}`);
|
||||
return { pages, cappedAtCount: null, warnings };
|
||||
}
|
||||
|
||||
for (const result of response.results) {
|
||||
pages.push(notionPickerPageFromSearchResult(result));
|
||||
if (pages.length >= cap) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.hasMore || !response.nextCursor || pages.length >= cap) {
|
||||
return {
|
||||
pages,
|
||||
cappedAtCount: response.hasMore ? cap : null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
|
||||
return { pages, cappedAtCount: cap, warnings };
|
||||
}
|
||||
|
||||
export async function resolveNotionWorkspaceLabel(api: NotionPickerApi, connectionId: string): Promise<string> {
|
||||
try {
|
||||
const bot = (await api.retrieveBotUser()) as NotionBotInfo;
|
||||
const workspaceName = typeof bot.bot?.workspace_name === 'string' ? bot.bot.workspace_name.trim() : '';
|
||||
if (workspaceName.length > 0) {
|
||||
return workspaceName;
|
||||
}
|
||||
const name = typeof bot.name === 'string' ? bot.name.trim() : '';
|
||||
return name.length > 0 ? name : connectionId;
|
||||
} catch {
|
||||
return connectionId;
|
||||
}
|
||||
}
|
||||
|
||||
function notionConnection(project: KloLocalProject, connectionId: string): KloProjectConnectionConfig {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) {
|
||||
throw new Error(`Connection "${connectionId}" not found`);
|
||||
}
|
||||
if (connection.driver !== 'notion') {
|
||||
throw new Error(`Connection "${connectionId}" is not a Notion connection`);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function applyNotionPickerWriteback(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
rootPageIds: string[],
|
||||
): Promise<void> {
|
||||
if (rootPageIds.length === 0) {
|
||||
throw new Error('connection notion pick requires at least one root page id');
|
||||
}
|
||||
|
||||
const existing = notionConnection(project, connectionId);
|
||||
const nextConfig = {
|
||||
...project.config,
|
||||
connections: {
|
||||
...project.config.connections,
|
||||
[connectionId]: {
|
||||
...existing,
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: rootPageIds,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(nextConfig),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
`Pick Notion roots: ${connectionId} (${rootPageIds.length} pages)`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runKloConnectionNotion(
|
||||
args: KloConnectionNotionArgs,
|
||||
io: KloCliIo = process,
|
||||
deps: KloConnectionNotionDeps = {},
|
||||
): Promise<number> {
|
||||
try {
|
||||
assertSafeConnectionId(args.connectionId);
|
||||
const loadProject = deps.loadProject ?? loadKloProject;
|
||||
|
||||
if (args.mode === 'interactive') {
|
||||
const project = await loadProject({ projectDir: args.projectDir });
|
||||
const rawConnection = notionConnection(project, args.connectionId);
|
||||
const notion = parseNotionConnectionConfig(rawConnection);
|
||||
const authToken = await resolveNotionAuthToken(notion.auth_token_ref, { env: deps.env });
|
||||
const api = deps.createNotionApi ? deps.createNotionApi(authToken) : new NotionClient(authToken);
|
||||
const discovery = await discoverNotionPickerPages(api);
|
||||
const tree = buildPickerTree(discovery.pages);
|
||||
const initialState = buildInitialState({
|
||||
tree,
|
||||
existingRootPageIds: notion.root_page_ids,
|
||||
currentCrawlMode: notion.crawl_mode,
|
||||
});
|
||||
const preLoadWarnings = [...discovery.warnings, ...initialState.preLoadWarnings];
|
||||
const renderState =
|
||||
preLoadWarnings.length > 0
|
||||
? {
|
||||
...initialState,
|
||||
preLoadWarnings,
|
||||
}
|
||||
: initialState;
|
||||
for (const warning of preLoadWarnings) {
|
||||
io.stderr.write(`${warning}\n`);
|
||||
}
|
||||
const workspaceLabel = await resolveNotionWorkspaceLabel(api, args.connectionId);
|
||||
const result = await (deps.renderPicker ?? renderNotionPickerTui)(
|
||||
{
|
||||
initialState: renderState,
|
||||
connectionId: args.connectionId,
|
||||
workspaceLabel,
|
||||
cappedAtCount: discovery.cappedAtCount,
|
||||
currentCrawlMode: notion.crawl_mode,
|
||||
},
|
||||
io as NotionPickerTuiIo,
|
||||
);
|
||||
if (result.kind === 'quit') {
|
||||
io.stdout.write('No changes saved.\n');
|
||||
return 0;
|
||||
}
|
||||
await applyNotionPickerWriteback(project, args.connectionId, result.rootPageIds);
|
||||
io.stdout.write(`Connection: ${args.connectionId}\n`);
|
||||
io.stdout.write(`rootPageIds: ${result.rootPageIds.length}\n`);
|
||||
io.stdout.write('crawlMode: selected_roots\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const project = await loadProject({ projectDir: args.projectDir });
|
||||
await applyNotionPickerWriteback(project, args.connectionId, args.rootPageIds);
|
||||
io.stdout.write(`Connection: ${args.connectionId}\n`);
|
||||
io.stdout.write(`rootPageIds: ${args.rootPageIds.length}\n`);
|
||||
io.stdout.write('crawlMode: selected_roots\n');
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
26
packages/cli/src/commands/demo-commands.test.ts
Normal file
26
packages/cli/src/commands/demo-commands.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveDemoCommandOptions } from './demo-commands.js';
|
||||
|
||||
describe('resolveDemoCommandOptions', () => {
|
||||
it('lets parent --no-input override a child default from optsWithGlobals', () => {
|
||||
const rootCommand = {
|
||||
opts: () => ({}),
|
||||
};
|
||||
const setupCommand = {
|
||||
parent: rootCommand,
|
||||
opts: () => ({ input: false }),
|
||||
getOptionValueSource: (name: string) => (name === 'input' ? 'cli' : undefined),
|
||||
};
|
||||
const demoCommand = {
|
||||
parent: setupCommand,
|
||||
opts: () => ({ input: true, mode: 'seeded' }),
|
||||
optsWithGlobals: () => ({ input: true, mode: 'seeded' }),
|
||||
getOptionValueSource: (name: string) => (name === 'input' ? 'default' : name === 'mode' ? 'default' : undefined),
|
||||
};
|
||||
|
||||
expect(resolveDemoCommandOptions<{ input: boolean; mode: string }>(demoCommand)).toEqual({
|
||||
input: false,
|
||||
mode: 'seeded',
|
||||
});
|
||||
});
|
||||
});
|
||||
273
packages/cli/src/commands/demo-commands.ts
Normal file
273
packages/cli/src/commands/demo-commands.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import {
|
||||
type CommandWithGlobalOptions,
|
||||
type KloCliCommandContext,
|
||||
resolveCommandProjectDirOverride,
|
||||
} from '../cli-program.js';
|
||||
import {
|
||||
type KloDemoArgs,
|
||||
type KloDemoInputMode,
|
||||
type KloDemoMode,
|
||||
type KloDemoOutputMode,
|
||||
} from '../demo.js';
|
||||
import { defaultDemoProjectDir } from '../demo-assets.js';
|
||||
import { resolveProjectDir } from '../project-dir.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/demo-commands');
|
||||
|
||||
interface DemoOptions {
|
||||
plain?: boolean;
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
projectDir?: string;
|
||||
}
|
||||
|
||||
function demoOutputMode(options: { plain?: boolean; json?: boolean }): KloDemoOutputMode {
|
||||
if (options.json === true) {
|
||||
return 'json';
|
||||
}
|
||||
if (options.plain === true) {
|
||||
return 'plain';
|
||||
}
|
||||
return 'viz';
|
||||
}
|
||||
|
||||
function demoDoctorOutputMode(options: { json?: boolean }): 'plain' | 'json' {
|
||||
return options.json === true ? 'json' : 'plain';
|
||||
}
|
||||
|
||||
function demoInspectOutputMode(options: { plain?: boolean; json?: boolean }): KloDemoOutputMode {
|
||||
if (options.json === true) {
|
||||
return 'json';
|
||||
}
|
||||
return 'plain';
|
||||
}
|
||||
|
||||
function demoInputMode(options: { input?: boolean }): { inputMode?: KloDemoInputMode } {
|
||||
return options.input === false ? { inputMode: 'disabled' } : {};
|
||||
}
|
||||
|
||||
function demoProjectDir(options: { projectDir?: string }, command: CommandWithGlobalOptions): string {
|
||||
return resolveProjectDir(
|
||||
options.projectDir ?? resolveCommandProjectDirOverride(command),
|
||||
defaultDemoProjectDir(),
|
||||
);
|
||||
}
|
||||
|
||||
type CommandOptionSourceReader = {
|
||||
getOptionValueSource?: (name: string) => string | undefined;
|
||||
parent?: unknown;
|
||||
};
|
||||
|
||||
function inheritedOptionSource(command: CommandOptionSourceReader, key: string): string | undefined {
|
||||
let current = command.parent as (CommandOptionSourceReader & { opts?: () => Record<string, unknown> }) | undefined;
|
||||
while (current) {
|
||||
const source = current.getOptionValueSource?.(key);
|
||||
if (source !== undefined) {
|
||||
return source;
|
||||
}
|
||||
current = current.parent as (CommandOptionSourceReader & { opts?: () => Record<string, unknown> }) | undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function definedOptions(
|
||||
options: Record<string, unknown>,
|
||||
inherited: Record<string, unknown> = {},
|
||||
command?: CommandOptionSourceReader,
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(options).filter(([key, value]) => {
|
||||
if (value === undefined) return false;
|
||||
if (key === 'input' && value === true && inherited.input === false) return false;
|
||||
if (
|
||||
key === 'mode' &&
|
||||
command?.getOptionValueSource?.(key) === 'default' &&
|
||||
inherited[key] !== undefined &&
|
||||
inherited[key] !== value &&
|
||||
inheritedOptionSource(command, key) === 'cli'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDemoCommandOptions<T>(command: { opts: () => T; optsWithGlobals?: () => T; parent?: unknown }): T {
|
||||
const chain: Array<{ opts?: () => Record<string, unknown>; parent?: unknown }> = [];
|
||||
let current = command.parent as { opts?: () => Record<string, unknown>; parent?: unknown } | undefined;
|
||||
while (current) {
|
||||
chain.unshift(current);
|
||||
current = current.parent as { opts?: () => Record<string, unknown>; parent?: unknown } | undefined;
|
||||
}
|
||||
const inherited = Object.assign({}, ...chain.map((parent) => definedOptions(parent.opts?.() ?? {})));
|
||||
|
||||
if (command.optsWithGlobals) {
|
||||
const withGlobals = {
|
||||
...inherited,
|
||||
...definedOptions(command.optsWithGlobals() as Record<string, unknown>, inherited, command),
|
||||
};
|
||||
return {
|
||||
...withGlobals,
|
||||
...definedOptions(command.opts() as Record<string, unknown>, withGlobals, command),
|
||||
} as T;
|
||||
}
|
||||
|
||||
return { ...inherited, ...definedOptions(command.opts() as Record<string, unknown>, inherited, command) } as T;
|
||||
}
|
||||
|
||||
async function runDemoArgs(context: KloCliCommandContext, args: KloDemoArgs): Promise<void> {
|
||||
const runner = context.deps.demo ?? (await import('../demo.js')).runKloDemo;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerDemoCommands(
|
||||
program: Command,
|
||||
context: KloCliCommandContext,
|
||||
options: { description?: string } = {},
|
||||
): void {
|
||||
const demo = program
|
||||
.command('demo')
|
||||
.description(options.description ?? 'Run the pre-seeded KLO demo or a full LLM-backed demo')
|
||||
.addOption(
|
||||
new Option('--mode <mode>', 'Demo mode: seeded (default), replay, or full')
|
||||
.choices(['seeded', 'replay', 'full'])
|
||||
.default('seeded'),
|
||||
)
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.showHelpAfterError()
|
||||
.action(async (options: { mode: 'seeded' | 'replay' | 'full' } & DemoOptions, command) => {
|
||||
const resolvedOptions = resolveDemoCommandOptions<typeof options>(command);
|
||||
await runDemoArgs(context, {
|
||||
command: resolvedOptions.mode,
|
||||
projectDir: demoProjectDir(resolvedOptions, command),
|
||||
outputMode: demoOutputMode(resolvedOptions),
|
||||
...demoInputMode(resolvedOptions),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('init')
|
||||
.description('Initialize the packaged demo project')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.option('--force', 'Recreate an existing demo project', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { projectDir?: string; force?: boolean; input?: boolean } }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'init',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
force: options.force === true,
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('reset')
|
||||
.description('Reset the packaged demo project')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.option('--force', 'Recreate the demo project without prompting', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { projectDir?: string; force?: boolean; input?: boolean } }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'reset',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
force: options.force === true,
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('replay')
|
||||
.description('Replay the packaged demo memory-flow')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'replay',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('scan')
|
||||
.description('Run the packaged demo scan')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { projectDir?: string; input?: boolean } }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'scan',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('inspect')
|
||||
.description('Inspect packaged demo outputs')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'inspect',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoInspectOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('doctor')
|
||||
.description('Check packaged demo readiness')
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'doctor',
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoDoctorOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
demo
|
||||
.command('ingest')
|
||||
.description('Run packaged demo ingest')
|
||||
.addOption(
|
||||
new Option('--mode <mode>', 'Demo ingest mode: full or seeded')
|
||||
.choices(['full', 'seeded'])
|
||||
.default('full'),
|
||||
)
|
||||
.option('--project-dir <path>', 'Demo project directory')
|
||||
.addOption(new Option('--plain', 'Print plain text output instead of the visual demo').conflicts('json'))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts('plain'))
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (_options, command: { opts: () => { mode: KloDemoMode } & DemoOptions }) => {
|
||||
const options = resolveDemoCommandOptions(command);
|
||||
await runDemoArgs(context, {
|
||||
command: 'ingest',
|
||||
mode: options.mode,
|
||||
projectDir: demoProjectDir(options, command),
|
||||
outputMode: demoOutputMode(options),
|
||||
...demoInputMode(options),
|
||||
});
|
||||
});
|
||||
}
|
||||
53
packages/cli/src/commands/doctor-commands.ts
Normal file
53
packages/cli/src/commands/doctor-commands.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { Command } from '@commander-js/extra-typings';
|
||||
import { type CommandWithGlobalOptions, type KloCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KloDoctorArgs } from '../doctor.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/doctor-commands');
|
||||
|
||||
function outputMode(options: { json?: boolean }): 'plain' | 'json' {
|
||||
return options.json === true ? 'json' : 'plain';
|
||||
}
|
||||
|
||||
function inputMode(options: { input?: boolean }): { inputMode?: 'disabled' } {
|
||||
return options.input === false ? { inputMode: 'disabled' } : {};
|
||||
}
|
||||
|
||||
async function runDoctorArgs(context: KloCliCommandContext, args: KloDoctorArgs): Promise<void> {
|
||||
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKloDoctor;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerDoctorCommands(program: Command, context: KloCliCommandContext): void {
|
||||
const doctor = program
|
||||
.command('doctor')
|
||||
.description('Check KLO setup, project, and demo readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (options: { json?: boolean; input?: boolean }, command) => {
|
||||
await runDoctorArgs(context, {
|
||||
command: 'project',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
outputMode: outputMode(options),
|
||||
...inputMode(options),
|
||||
});
|
||||
});
|
||||
|
||||
doctor
|
||||
.command('setup')
|
||||
.description('Check KLO install, build, and local runtime readiness')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(
|
||||
async (
|
||||
_options: { json?: boolean; input?: boolean },
|
||||
command: CommandWithGlobalOptions,
|
||||
) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as {
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
};
|
||||
await runDoctorArgs(context, { command: 'setup', outputMode: outputMode(options), ...inputMode(options) });
|
||||
},
|
||||
);
|
||||
}
|
||||
171
packages/cli/src/commands/ingest-commands.ts
Normal file
171
packages/cli/src/commands/ingest-commands.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { resolve } from 'node:path';
|
||||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import { type KloCliCommandContext, type OutputModeOptions, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KloCliDeps, KloCliIo } from '../index.js';
|
||||
import type { KloIngestArgs, KloIngestOutputMode } from '../ingest.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/ingest-commands');
|
||||
|
||||
interface IngestCommandOptions {
|
||||
runIngestWithProgress: (
|
||||
args: KloIngestArgs,
|
||||
io: KloCliIo,
|
||||
deps: KloCliDeps,
|
||||
defaultRunIngest: (args: KloIngestArgs, io: KloCliIo) => Promise<number>,
|
||||
) => Promise<number>;
|
||||
}
|
||||
|
||||
function outputMode(options: OutputModeOptions): KloIngestOutputMode {
|
||||
if (options.json === true) {
|
||||
return 'json';
|
||||
}
|
||||
if (options.viz === true) {
|
||||
return 'viz';
|
||||
}
|
||||
return 'plain';
|
||||
}
|
||||
|
||||
function watchOutputMode(options: OutputModeOptions): KloIngestOutputMode {
|
||||
if (options.json === true) {
|
||||
return 'json';
|
||||
}
|
||||
if (options.plain === true) {
|
||||
return 'plain';
|
||||
}
|
||||
return 'viz';
|
||||
}
|
||||
|
||||
function inputMode(options: OutputModeOptions): Pick<KloIngestArgs, 'inputMode'> {
|
||||
return options.input === false ? { inputMode: 'disabled' } : {};
|
||||
}
|
||||
|
||||
async function runIngestArgs(
|
||||
context: KloCliCommandContext,
|
||||
args: KloIngestArgs,
|
||||
options: IngestCommandOptions,
|
||||
): Promise<void> {
|
||||
const { runKloIngest } = await import('../ingest.js');
|
||||
context.setExitCode(await options.runIngestWithProgress(args, context.io, context.deps, runKloIngest));
|
||||
}
|
||||
|
||||
export function registerIngestCommands(
|
||||
program: Command,
|
||||
context: KloCliCommandContext,
|
||||
commandOptions: IngestCommandOptions,
|
||||
): void {
|
||||
const ingest = program
|
||||
.command('ingest')
|
||||
.description('Run or inspect local ingest memory-flow output')
|
||||
.showHelpAfterError();
|
||||
|
||||
ingest.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('ingest', actionCommand);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('run')
|
||||
.description('Run local ingest for one configured connection and source adapter')
|
||||
.requiredOption('--connection-id <connectionId>', 'KLO connection id')
|
||||
.requiredOption('--adapter <adapter>', 'Ingest source adapter name')
|
||||
.option('--source-dir <path>', 'Directory containing source files')
|
||||
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
|
||||
.option('--debug-llm-request-file <path>', 'Write sanitized LLM request structure to a JSONL file')
|
||||
.option('--report-file <path>', 'Unsupported for ingest run; use ingest status/watch instead')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
|
||||
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
|
||||
.option('--no-input', 'Disable interactive terminal input for visualization')
|
||||
.action(async (options, command) => {
|
||||
if (options.reportFile) {
|
||||
throw new Error('--report-file is only supported for ingest status/watch');
|
||||
}
|
||||
await runIngestArgs(
|
||||
context,
|
||||
{
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
adapter: options.adapter,
|
||||
sourceDir: options.sourceDir ? resolve(options.sourceDir) : undefined,
|
||||
databaseIntrospectionUrl: options.databaseIntrospectionUrl || undefined,
|
||||
...(options.debugLlmRequestFile ? { debugLlmRequestFile: resolve(options.debugLlmRequestFile) } : {}),
|
||||
outputMode: outputMode(options),
|
||||
...inputMode(options),
|
||||
},
|
||||
commandOptions,
|
||||
);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('status')
|
||||
.description('Print status for the latest or selected stored local ingest run or report file')
|
||||
.argument('[runId]', 'Local ingest run id, report id, run id, or job id')
|
||||
.option('--report-file <path>', 'Bundle ingest report JSON file to render')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
|
||||
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
|
||||
.option('--no-input', 'Disable interactive terminal input for visualization')
|
||||
.action(async (runId: string | undefined, options, command) => {
|
||||
await runIngestArgs(
|
||||
context,
|
||||
{
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
...(options.reportFile ? { reportFile: resolve(options.reportFile) } : {}),
|
||||
outputMode: outputMode(options),
|
||||
...inputMode(options),
|
||||
},
|
||||
commandOptions,
|
||||
);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('watch')
|
||||
.description('Open the latest or selected stored ingest visual report')
|
||||
.argument('[runId]', 'Local ingest run id, report id, run id, or job id')
|
||||
.option('--report-file <path>', 'Bundle ingest report JSON file to render')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
|
||||
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
|
||||
.option('--no-input', 'Disable interactive terminal input for visualization')
|
||||
.action(async (runId: string | undefined, options, command) => {
|
||||
await runIngestArgs(
|
||||
context,
|
||||
{
|
||||
command: 'watch',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
...(options.reportFile ? { reportFile: resolve(options.reportFile) } : {}),
|
||||
outputMode: watchOutputMode(options),
|
||||
...inputMode(options),
|
||||
},
|
||||
commandOptions,
|
||||
);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('replay')
|
||||
.description('Replay a stored ingest run or bundle report through memory-flow output')
|
||||
.argument('<runId>', 'Local ingest run id, report id, run id, or job id')
|
||||
.option('--report-file <path>', 'Bundle ingest report JSON file to render')
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json', 'viz']))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain', 'viz']))
|
||||
.addOption(new Option('--viz', 'Render memory-flow TUI output').conflicts(['plain', 'json']))
|
||||
.option('--no-input', 'Disable interactive terminal input for visualization')
|
||||
.action(async (runId: string, options, command) => {
|
||||
await runIngestArgs(
|
||||
context,
|
||||
{
|
||||
command: 'replay',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
...(options.reportFile ? { reportFile: resolve(options.reportFile) } : {}),
|
||||
outputMode: outputMode(options),
|
||||
...inputMode(options),
|
||||
},
|
||||
commandOptions,
|
||||
);
|
||||
});
|
||||
}
|
||||
90
packages/cli/src/commands/knowledge-commands.ts
Normal file
90
packages/cli/src/commands/knowledge-commands.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import { collectOption, type KloCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { wikiWriteCommandSchema } from '../command-schemas.js';
|
||||
import type { KloKnowledgeArgs } from '../knowledge.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/knowledge-commands');
|
||||
|
||||
async function runKnowledgeArgs(context: KloCliCommandContext, args: KloKnowledgeArgs): Promise<void> {
|
||||
const runner = context.deps.knowledge ?? (await import('../knowledge.js')).runKloKnowledge;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerWikiCommands(program: Command, context: KloCliCommandContext): void {
|
||||
const wiki = program
|
||||
.command('wiki')
|
||||
.description('List, read, search, or write local wiki pages')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
wiki
|
||||
.command('list')
|
||||
.description('List local wiki pages')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (options: { userId: string }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
userId: options.userId,
|
||||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('read')
|
||||
.description('Read one local wiki page')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (key: string, options: { userId: string }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
userId: options.userId,
|
||||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('search')
|
||||
.description('Search local wiki pages')
|
||||
.argument('<query>', 'Search query')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (query: string, options: { userId: string }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
query,
|
||||
userId: options.userId,
|
||||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('write')
|
||||
.description('Write one local wiki page')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.addOption(new Option('--scope <scope>', 'global or user').choices(['global', 'user']).default('global'))
|
||||
.requiredOption('--summary <summary>', 'Wiki summary')
|
||||
.requiredOption('--content <content>', 'Wiki content')
|
||||
.option('--tag <tag>', 'Wiki tag; repeatable', collectOption, [])
|
||||
.option('--ref <ref>', 'Wiki ref; repeatable', collectOption, [])
|
||||
.option('--sl-ref <ref>', 'Semantic-layer ref; repeatable', collectOption, [])
|
||||
.action(async (key: string, options, command) => {
|
||||
const args = wikiWriteCommandSchema.parse({
|
||||
command: 'write',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
scope: options.scope === 'user' ? 'USER' : 'GLOBAL',
|
||||
userId: options.userId,
|
||||
summary: options.summary,
|
||||
content: options.content,
|
||||
tags: options.tag,
|
||||
refs: options.ref,
|
||||
slRefs: options.slRef,
|
||||
});
|
||||
await runKnowledgeArgs(context, args);
|
||||
});
|
||||
}
|
||||
109
packages/cli/src/commands/public-ingest-commands.ts
Normal file
109
packages/cli/src/commands/public-ingest-commands.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { InvalidArgumentError, type Command } from '@commander-js/extra-typings';
|
||||
import { type KloCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import { publicIngestReadCommandSchema, publicIngestRunCommandSchema } from '../command-schemas.js';
|
||||
import type { KloPublicIngestArgs, KloPublicIngestInputMode } from '../public-ingest.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/public-ingest-commands');
|
||||
|
||||
interface PublicIngestOptions {
|
||||
all?: boolean;
|
||||
json?: boolean;
|
||||
input?: boolean;
|
||||
}
|
||||
|
||||
function inputMode(options: { input?: boolean }): KloPublicIngestInputMode {
|
||||
return options.input === false ? 'disabled' : 'auto';
|
||||
}
|
||||
|
||||
async function runPublicIngestArgs(context: KloCliCommandContext, args: KloPublicIngestArgs): Promise<void> {
|
||||
const runner = context.deps.publicIngest ?? (await import('../public-ingest.js')).runKloPublicIngest;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
function parsePublicIngestConnectionId(value: string): string {
|
||||
if (value === 'run') {
|
||||
throw new InvalidArgumentError('run is reserved; use klo dev ingest run for low-level adapter syntax');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function registerPublicIngestCommands(program: Command, context: KloCliCommandContext): void {
|
||||
const ingest = program
|
||||
.command('ingest')
|
||||
.description('Build and refresh KLO context from configured sources')
|
||||
.usage('[options] [connectionId]')
|
||||
.argument('[connectionId]', 'Connection id to ingest', parsePublicIngestConnectionId)
|
||||
.option('--all', 'Ingest every eligible configured source', false)
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.addHelpText(
|
||||
'after',
|
||||
[
|
||||
'',
|
||||
'Examples:',
|
||||
' klo ingest <connectionId> [options]',
|
||||
' klo ingest --all [options]',
|
||||
' klo ingest status [runId] [options]',
|
||||
' klo ingest watch [runId] [options]',
|
||||
'',
|
||||
'Project directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.',
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
.showHelpAfterError()
|
||||
.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('ingest', actionCommand);
|
||||
})
|
||||
.action(async (connectionId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = command.opts();
|
||||
if (options.all === true && connectionId) {
|
||||
throw new Error('klo ingest accepts either --all or <connectionId>, not both');
|
||||
}
|
||||
const args = publicIngestRunCommandSchema.parse({
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(connectionId ? { targetConnectionId: connectionId } : {}),
|
||||
all: options.all === true,
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('status')
|
||||
.description('Print status for the latest or selected public ingest run')
|
||||
.argument('[runId]', 'Public ingest run id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as PublicIngestOptions;
|
||||
const args = publicIngestReadCommandSchema.parse({
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('watch')
|
||||
.description('Open the latest or selected public ingest visual report')
|
||||
.argument('[runId]', 'Public ingest run id')
|
||||
.option('--json', 'Print JSON output instead of the visual report', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, _options: PublicIngestOptions, command) => {
|
||||
const options = (command.optsWithGlobals ? command.optsWithGlobals() : command.opts()) as PublicIngestOptions;
|
||||
const args = publicIngestReadCommandSchema.parse({
|
||||
command: 'watch',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
inputMode: inputMode(options),
|
||||
});
|
||||
await runPublicIngestArgs(context, args);
|
||||
});
|
||||
}
|
||||
353
packages/cli/src/commands/scan-commands.ts
Normal file
353
packages/cli/src/commands/scan-commands.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import { type KloCliCommandContext, parsePositiveIntegerOption, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KloScanArgs } from '../scan.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/scan-commands');
|
||||
|
||||
async function runScanArgs(context: KloCliCommandContext, args: KloScanArgs): Promise<void> {
|
||||
const runner = context.deps.scan ?? (await import('../scan.js')).runKloScan;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
type KloScanModeOption = Extract<KloScanArgs, { command: 'run' }>['mode'];
|
||||
|
||||
function parseScanModeOption(value: string): KloScanModeOption {
|
||||
if (value === 'structural' || value === 'enriched' || value === 'relationships') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed choices are structural, enriched, relationships');
|
||||
}
|
||||
|
||||
type KloRelationshipStatusOption = Extract<KloScanArgs, { command: 'relationships' }>['status'];
|
||||
type KloRelationshipFeedbackDecisionOption = Extract<KloScanArgs, { command: 'relationshipFeedback' }>['decision'];
|
||||
|
||||
function parseRelationshipStatusOption(value: string): KloRelationshipStatusOption {
|
||||
if (value === 'accepted' || value === 'review' || value === 'rejected' || value === 'skipped' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed choices are accepted, review, rejected, skipped, all');
|
||||
}
|
||||
|
||||
function parseRelationshipFeedbackDecisionOption(value: string): KloRelationshipFeedbackDecisionOption {
|
||||
if (value === 'accepted' || value === 'rejected' || value === 'all') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed choices are accepted, rejected, all');
|
||||
}
|
||||
|
||||
function parseNonEmptyOption(value: string): string {
|
||||
if (value.trim().length === 0) {
|
||||
throw new InvalidArgumentError('must not be empty');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRelationshipCalibrationThreshold(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
throw new InvalidArgumentError('Allowed range is 0 through 1');
|
||||
}
|
||||
|
||||
function relationshipDecisionArgs(options: {
|
||||
accept?: string;
|
||||
reject?: string;
|
||||
reviewer?: string;
|
||||
note?: string;
|
||||
json?: boolean;
|
||||
}): Pick<
|
||||
Extract<KloScanArgs, { command: 'relationshipDecision' }>,
|
||||
'candidateId' | 'decision' | 'reviewer' | 'note' | 'json'
|
||||
> | null {
|
||||
const decisionCount = [options.accept !== undefined, options.reject !== undefined].filter(Boolean).length;
|
||||
if (decisionCount > 1) {
|
||||
throw new Error('Only one relationship review decision option can be used: --accept and --reject conflict');
|
||||
}
|
||||
if (options.accept !== undefined) {
|
||||
return {
|
||||
candidateId: options.accept,
|
||||
decision: 'accepted',
|
||||
reviewer: options.reviewer ?? 'klo',
|
||||
note: options.note ?? null,
|
||||
json: options.json === true,
|
||||
};
|
||||
}
|
||||
if (options.reject !== undefined) {
|
||||
return {
|
||||
candidateId: options.reject,
|
||||
decision: 'rejected',
|
||||
reviewer: options.reviewer ?? 'klo',
|
||||
note: options.note ?? null,
|
||||
json: options.json === true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectRelationshipCandidateOption(value: string, previous: string[]): string[] {
|
||||
return [...previous, parseNonEmptyOption(value)];
|
||||
}
|
||||
|
||||
export function registerScanCommands(program: Command, context: KloCliCommandContext): void {
|
||||
const scan = program
|
||||
.command('scan')
|
||||
.description('Run or inspect standalone connection scans')
|
||||
.argument('[connectionId]', 'KLO connection id to scan')
|
||||
.option(
|
||||
'--mode <mode>',
|
||||
'Scan mode: structural, enriched, relationships (default: structural)',
|
||||
parseScanModeOption,
|
||||
)
|
||||
.option('--dry-run', 'Run without writing scan results', false)
|
||||
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
)
|
||||
.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('scan', actionCommand);
|
||||
})
|
||||
.action(async (connectionId: string | undefined, options, command) => {
|
||||
if (!connectionId) {
|
||||
scan.outputHelp();
|
||||
context.io.stderr.write('klo dev scan requires <connectionId> or a subcommand\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const mode = options.mode ?? 'structural';
|
||||
await runScanArgs(context, {
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
mode,
|
||||
detectRelationships: mode === 'relationships',
|
||||
dryRun: options.dryRun === true,
|
||||
databaseIntrospectionUrl: options.databaseIntrospectionUrl,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('status')
|
||||
.description('Print status for a local scan run')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `klo dev scan` (default: KLO_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, _options: unknown, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('report')
|
||||
.description('Print a local scan report')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option('--json', 'Print the raw scan report JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `klo dev scan` (default: KLO_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'report',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationships')
|
||||
.description('Print relationship artifacts for a local scan run')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Relationship status: accepted, review, rejected, skipped, all',
|
||||
parseRelationshipStatusOption,
|
||||
'review',
|
||||
)
|
||||
.option('--limit <count>', 'Maximum relationships to print per status', parsePositiveIntegerOption, 25)
|
||||
.addOption(
|
||||
new Option('--accept <candidateId>', 'Record a reviewer accepted decision for a relationship candidate')
|
||||
.argParser(parseNonEmptyOption)
|
||||
.conflicts('reject'),
|
||||
)
|
||||
.addOption(
|
||||
new Option('--reject <candidateId>', 'Record a reviewer rejected decision for a relationship candidate')
|
||||
.argParser(parseNonEmptyOption)
|
||||
.conflicts('accept'),
|
||||
)
|
||||
.option('--note <text>', 'Attach a note when recording a relationship review decision')
|
||||
.option('--reviewer <name>', 'Reviewer name for a relationship review decision')
|
||||
.option('--json', 'Print relationship artifacts as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `klo dev scan` (default: KLO_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
const decision = relationshipDecisionArgs(options);
|
||||
if (decision) {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipDecision',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
candidateId: decision.candidateId,
|
||||
decision: decision.decision,
|
||||
reviewer: decision.reviewer,
|
||||
note: decision.note,
|
||||
json: decision.json,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runScanArgs(context, {
|
||||
command: 'relationships',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
status: options.status,
|
||||
json: options.json === true,
|
||||
limit: options.limit,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-apply')
|
||||
.description('Apply accepted relationship review decisions as manual manifest joins')
|
||||
.argument('<runId>', 'Local scan run id')
|
||||
.option('--all-accepted', 'Apply all accepted relationship review decisions for the scan run', false)
|
||||
.option(
|
||||
'--candidate <candidateId>',
|
||||
'Apply one accepted relationship review decision',
|
||||
collectRelationshipCandidateOption,
|
||||
[],
|
||||
)
|
||||
.option('--dry-run', 'Preview relationships that would be written without rewriting manifest shards', false)
|
||||
.option('--json', 'Print the apply result as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `klo dev scan` (default: KLO_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (runId: string, options, command) => {
|
||||
const parentOptions = command.parent?.opts() as { dryRun?: boolean } | undefined;
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipApply',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
runId,
|
||||
applyAllAccepted: options.allAccepted === true,
|
||||
candidateIds: options.candidate,
|
||||
dryRun: options.dryRun === true || parentOptions?.dryRun === true,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-feedback')
|
||||
.description('Export persisted relationship review decisions as calibration labels')
|
||||
.option('--connection <connectionId>', 'Only export labels for one KLO connection')
|
||||
.option(
|
||||
'--decision <decision>',
|
||||
'Relationship feedback decision: accepted, rejected, all',
|
||||
parseRelationshipFeedbackDecisionOption,
|
||||
'all',
|
||||
)
|
||||
.addOption(new Option('--json', 'Print the export as JSON').default(false).conflicts('jsonl'))
|
||||
.addOption(new Option('--jsonl', 'Print labels as newline-delimited JSON').default(false).conflicts('json'))
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `klo dev scan` (default: KLO_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipFeedback',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
decision: options.decision,
|
||||
json: options.json === true,
|
||||
jsonl: options.jsonl === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-calibration')
|
||||
.description('Summarize relationship feedback labels against current score thresholds')
|
||||
.option('--connection <connectionId>', 'Only calibrate labels for one KLO connection')
|
||||
.option(
|
||||
'--decision <decision>',
|
||||
'Relationship feedback decision: accepted, rejected, all',
|
||||
parseRelationshipFeedbackDecisionOption,
|
||||
'all',
|
||||
)
|
||||
.option(
|
||||
'--accept-threshold <value>',
|
||||
'Score threshold treated as predicted accepted',
|
||||
parseRelationshipCalibrationThreshold,
|
||||
0.85,
|
||||
)
|
||||
.option(
|
||||
'--review-threshold <value>',
|
||||
'Score threshold treated as predicted review',
|
||||
parseRelationshipCalibrationThreshold,
|
||||
0.55,
|
||||
)
|
||||
.option('--json', 'Print the calibration report as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `klo dev scan` (default: KLO_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipCalibration',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
decision: options.decision,
|
||||
acceptThreshold: options.acceptThreshold,
|
||||
reviewThreshold: options.reviewThreshold,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
scan
|
||||
.command('relationship-thresholds')
|
||||
.description('Evaluate relationship feedback labels for offline threshold advice')
|
||||
.option('--connection <connectionId>', 'Only evaluate labels for one KLO connection')
|
||||
.option(
|
||||
'--min-total-labels <count>',
|
||||
'Minimum scored labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
20,
|
||||
)
|
||||
.option(
|
||||
'--min-accepted-labels <count>',
|
||||
'Minimum accepted labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
5,
|
||||
)
|
||||
.option(
|
||||
'--min-rejected-labels <count>',
|
||||
'Minimum rejected labels before advice can be ready',
|
||||
parsePositiveIntegerOption,
|
||||
5,
|
||||
)
|
||||
.option('--json', 'Print the threshold advice report as JSON', false)
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\n--project-dir is inherited from `klo dev scan` (default: KLO_PROJECT_DIR or current working directory).\n',
|
||||
)
|
||||
.action(async (options, command) => {
|
||||
await runScanArgs(context, {
|
||||
command: 'relationshipThresholds',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connection ?? null,
|
||||
minTotalLabels: options.minTotalLabels,
|
||||
minAcceptedLabels: options.minAcceptedLabels,
|
||||
minRejectedLabels: options.minRejectedLabels,
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
47
packages/cli/src/commands/serve-commands.ts
Normal file
47
packages/cli/src/commands/serve-commands.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { type Command, InvalidArgumentError } from '@commander-js/extra-typings';
|
||||
import { type KloCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KloServeArgs } from '../serve.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/serve-commands');
|
||||
|
||||
function parseMcp(value: string): 'stdio' {
|
||||
if (value === 'stdio') {
|
||||
return 'stdio';
|
||||
}
|
||||
throw new InvalidArgumentError('Only stdio is supported in this phase');
|
||||
}
|
||||
|
||||
export function registerServeCommands(program: Command, context: KloCliCommandContext): void {
|
||||
program
|
||||
.command('serve')
|
||||
.description('Run standalone KLO services such as MCP stdio')
|
||||
.requiredOption('--mcp <mode>', 'MCP transport mode', parseMcp)
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.option('--semantic-compute', 'Enable semantic-layer compute', false)
|
||||
.option('--semantic-compute-url <url>', 'HTTP semantic-layer compute URL')
|
||||
.option('--database-introspection-url <url>', 'Daemon URL for live-database introspection')
|
||||
.option('--execute-queries', 'Allow semantic-layer query execution', false)
|
||||
.option('--memory-capture', 'Enable memory capture', false)
|
||||
.option('--memory-model <model>', 'Memory capture model')
|
||||
.showHelpAfterError()
|
||||
.action(async (options, command): Promise<void> => {
|
||||
const semanticCompute = options.semanticCompute === true || Boolean(options.semanticComputeUrl);
|
||||
if (options.executeQueries === true && !semanticCompute) {
|
||||
throw new Error('--execute-queries requires --semantic-compute');
|
||||
}
|
||||
const args: KloServeArgs = {
|
||||
mcp: options.mcp,
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
userId: options.userId,
|
||||
semanticCompute,
|
||||
semanticComputeUrl: options.semanticComputeUrl,
|
||||
databaseIntrospectionUrl: options.databaseIntrospectionUrl,
|
||||
executeQueries: options.executeQueries === true,
|
||||
memoryCapture: options.memoryCapture === true,
|
||||
memoryModel: options.memoryModel,
|
||||
};
|
||||
const runner = context.deps.serveStdio ?? (await import('../serve.js')).runKloServeStdio;
|
||||
context.setExitCode(await runner(args));
|
||||
});
|
||||
}
|
||||
517
packages/cli/src/commands/setup-commands.ts
Normal file
517
packages/cli/src/commands/setup-commands.ts
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import type { KloCliCommandContext } from '../cli-program.js';
|
||||
import { resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KloSetupDatabaseDriver } from '../setup-databases.js';
|
||||
import type { KloSetupSourceType } from '../setup-sources.js';
|
||||
import { registerDemoCommands } from './demo-commands.js';
|
||||
|
||||
async function runSetupArgs(
|
||||
context: KloCliCommandContext,
|
||||
args: Parameters<NonNullable<typeof context.deps.setup>>[0],
|
||||
) {
|
||||
const runner = context.deps.setup ?? (await import('../setup.js')).runKloSetup;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
function positiveInteger(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`Expected a positive integer, received ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
|
||||
if (value === 'openai' || value === 'sentence-transformers') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError(`invalid choice '${value}'`);
|
||||
}
|
||||
|
||||
function databaseDriver(value: string): KloSetupDatabaseDriver {
|
||||
if (
|
||||
value === 'sqlite' ||
|
||||
value === 'postgres' ||
|
||||
value === 'mysql' ||
|
||||
value === 'clickhouse' ||
|
||||
value === 'sqlserver' ||
|
||||
value === 'bigquery' ||
|
||||
value === 'snowflake'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError(`invalid choice '${value}'`);
|
||||
}
|
||||
|
||||
function sourceType(value: string): KloSetupSourceType {
|
||||
if (
|
||||
value === 'dbt' ||
|
||||
value === 'metricflow' ||
|
||||
value === 'metabase' ||
|
||||
value === 'looker' ||
|
||||
value === 'lookml' ||
|
||||
value === 'notion'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError(`invalid choice '${value}'`);
|
||||
}
|
||||
|
||||
function agentScope(value: string): 'project' | 'global' {
|
||||
if (value === 'project' || value === 'global') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError(`invalid choice '${value}'`);
|
||||
}
|
||||
|
||||
function agentInstallMode(value: string): 'cli' | 'mcp' | 'both' {
|
||||
if (value === 'cli' || value === 'mcp' || value === 'both') {
|
||||
return value;
|
||||
}
|
||||
throw new InvalidArgumentError(`invalid choice '${value}'`);
|
||||
}
|
||||
|
||||
function positiveNumber(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new InvalidArgumentError(`Expected a positive integer, received ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionWasSpecified(command: Command, optionName: string): boolean {
|
||||
const commandWithSources = command as Command & {
|
||||
getOptionValueSource?: (name: string) => string | undefined;
|
||||
getOptionValueSourceWithGlobals?: (name: string) => string | undefined;
|
||||
};
|
||||
const source =
|
||||
commandWithSources.getOptionValueSourceWithGlobals?.(optionName) ??
|
||||
commandWithSources.getOptionValueSource?.(optionName);
|
||||
return source !== undefined && source !== 'default';
|
||||
}
|
||||
|
||||
function shouldShowSetupEntryMenu(
|
||||
options: {
|
||||
new?: boolean;
|
||||
existing?: boolean;
|
||||
agents?: boolean;
|
||||
target?: string;
|
||||
global?: boolean;
|
||||
project?: boolean;
|
||||
skipAgents?: boolean;
|
||||
yes?: boolean;
|
||||
input?: boolean;
|
||||
anthropicApiKeyEnv?: string;
|
||||
anthropicApiKeyFile?: string;
|
||||
anthropicModel?: string;
|
||||
skipLlm?: boolean;
|
||||
embeddingBackend?: string;
|
||||
embeddingApiKeyEnv?: string;
|
||||
embeddingApiKeyFile?: string;
|
||||
skipEmbeddings?: boolean;
|
||||
database?: KloSetupDatabaseDriver[];
|
||||
databaseConnectionId?: string[];
|
||||
newDatabaseConnectionId?: string;
|
||||
databaseUrl?: string;
|
||||
databaseSchema?: string[];
|
||||
enableHistoricSql?: boolean;
|
||||
disableHistoricSql?: boolean;
|
||||
historicSqlWindowDays?: number;
|
||||
historicSqlMinCalls?: number;
|
||||
historicSqlServiceAccountPattern?: string[];
|
||||
historicSqlRedactionPattern?: string[];
|
||||
skipDatabases?: boolean;
|
||||
source?: KloSetupSourceType;
|
||||
sourceConnectionId?: string;
|
||||
sourcePath?: string;
|
||||
sourceGitUrl?: string;
|
||||
sourceBranch?: string;
|
||||
sourceSubpath?: string;
|
||||
sourceAuthTokenRef?: string;
|
||||
sourceUrl?: string;
|
||||
sourceApiKeyRef?: string;
|
||||
sourceClientId?: string;
|
||||
sourceClientSecretRef?: string;
|
||||
sourceWarehouseConnectionId?: string;
|
||||
sourceProjectName?: string;
|
||||
sourceProfilesPath?: string;
|
||||
sourceTarget?: string;
|
||||
metabaseDatabaseId?: number;
|
||||
notionCrawlMode?: string;
|
||||
notionRootPageId?: string[];
|
||||
skipInitialSourceIngest?: boolean;
|
||||
skipSources?: boolean;
|
||||
},
|
||||
command: Command,
|
||||
): boolean {
|
||||
if (options.database && options.database.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if (options.databaseConnectionId && options.databaseConnectionId.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if (options.databaseSchema && options.databaseSchema.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if (options.historicSqlServiceAccountPattern && options.historicSqlServiceAccountPattern.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if (options.historicSqlRedactionPattern && options.historicSqlRedactionPattern.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if (options.notionRootPageId && options.notionRootPageId.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ![
|
||||
'new',
|
||||
'existing',
|
||||
'agents',
|
||||
'target',
|
||||
'global',
|
||||
'project',
|
||||
'skipAgents',
|
||||
'yes',
|
||||
'input',
|
||||
'anthropicApiKeyEnv',
|
||||
'anthropicApiKeyFile',
|
||||
'anthropicModel',
|
||||
'skipLlm',
|
||||
'embeddingBackend',
|
||||
'embeddingApiKeyEnv',
|
||||
'embeddingApiKeyFile',
|
||||
'skipEmbeddings',
|
||||
'newDatabaseConnectionId',
|
||||
'databaseUrl',
|
||||
'enableHistoricSql',
|
||||
'disableHistoricSql',
|
||||
'historicSqlWindowDays',
|
||||
'historicSqlMinCalls',
|
||||
'skipDatabases',
|
||||
'source',
|
||||
'sourceConnectionId',
|
||||
'sourcePath',
|
||||
'sourceGitUrl',
|
||||
'sourceBranch',
|
||||
'sourceSubpath',
|
||||
'sourceAuthTokenRef',
|
||||
'sourceUrl',
|
||||
'sourceApiKeyRef',
|
||||
'sourceClientId',
|
||||
'sourceClientSecretRef',
|
||||
'sourceWarehouseConnectionId',
|
||||
'sourceProjectName',
|
||||
'sourceProfilesPath',
|
||||
'sourceTarget',
|
||||
'metabaseDatabaseId',
|
||||
'notionCrawlMode',
|
||||
'skipInitialSourceIngest',
|
||||
'skipSources',
|
||||
].some((optionName) => optionWasSpecified(command, optionName));
|
||||
}
|
||||
|
||||
export function registerSetupCommands(program: Command, context: KloCliCommandContext): void {
|
||||
const setup = program
|
||||
.command('setup')
|
||||
.description('Set up or resume a local KLO project')
|
||||
.option('--project-dir <path>', 'KLO project directory')
|
||||
.option('--new', 'Create a new KLO project before setup', false)
|
||||
.option('--existing', 'Use an existing KLO project', false)
|
||||
.option('--agents', 'Install agent integration only', false)
|
||||
.addOption(
|
||||
new Option('--target <target>', 'Agent target').choices([
|
||||
'claude-code',
|
||||
'codex',
|
||||
'cursor',
|
||||
'opencode',
|
||||
'universal',
|
||||
]),
|
||||
)
|
||||
.addOption(new Option('--agent-scope <scope>', 'Agent install scope').argParser(agentScope).default('project'))
|
||||
.option('--project', 'Install agent integration into the project scope', false)
|
||||
.option('--global', 'Install agent integration into the global target scope', false)
|
||||
.addOption(
|
||||
new Option('--agent-install-mode <mode>', 'Agent install mode').argParser(agentInstallMode).default('cli'),
|
||||
)
|
||||
.option('--skip-agents', 'Leave agent integration incomplete for now', false)
|
||||
.option('--yes', 'Accept safe defaults in non-interactive setup', false)
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.option('--anthropic-api-key-env <name>', 'Environment variable containing the Anthropic API key')
|
||||
.option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key')
|
||||
.option('--anthropic-model <model>', 'Anthropic model ID to validate and save')
|
||||
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
|
||||
.addOption(new Option('--embedding-backend <backend>', 'Embedding backend').argParser(embeddingBackend))
|
||||
.option('--embedding-api-key-env <name>', 'Environment variable containing the embedding provider API key')
|
||||
.option('--embedding-api-key-file <path>', 'File containing the embedding provider API key')
|
||||
.addOption(new Option('--skip-embeddings', 'Leave embedding setup incomplete for now').hideHelp().default(false))
|
||||
.option(
|
||||
'--database <driver>',
|
||||
'Database driver to configure; repeatable',
|
||||
(value, previous: KloSetupDatabaseDriver[]) => {
|
||||
return [...previous, databaseDriver(value)];
|
||||
},
|
||||
[] as KloSetupDatabaseDriver[],
|
||||
)
|
||||
.option(
|
||||
'--database-connection-id <id>',
|
||||
'Existing selected connection id or new connection id',
|
||||
(value, previous: string[]) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.option('--new-database-connection-id <id>', 'Connection id for one new database connection', (value) => {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
|
||||
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection')
|
||||
.option(
|
||||
'--database-schema <schema>',
|
||||
'Database schema to include; repeatable',
|
||||
(value, previous: string[]) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.option('--enable-historic-sql', 'Enable Historic SQL when the selected database supports it', false)
|
||||
.option('--disable-historic-sql', 'Disable Historic SQL for the selected database', false)
|
||||
.option('--historic-sql-window-days <number>', 'Historic SQL query-history window', positiveInteger)
|
||||
.option(
|
||||
'--historic-sql-min-calls <number>',
|
||||
'Postgres Historic SQL pg_stat_statements minimum calls floor',
|
||||
positiveInteger,
|
||||
)
|
||||
.option(
|
||||
'--historic-sql-service-account-pattern <pattern>',
|
||||
'Historic SQL service-account regex; repeatable',
|
||||
(value, previous: string[]) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
'--historic-sql-redaction-pattern <pattern>',
|
||||
'Historic SQL SQL-literal redaction regex; repeatable',
|
||||
(value, previous: string[]) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.option('--skip-databases', 'Leave database setup incomplete; KLO cannot work until a primary source is added', false)
|
||||
.addOption(new Option('--source <type>', 'Source connector type').argParser(sourceType))
|
||||
.option('--source-connection-id <id>', 'Connection id for source setup')
|
||||
.option('--source-path <path>', 'Local source path for dbt, MetricFlow, or LookML')
|
||||
.option('--source-git-url <url>', 'Git URL for dbt, MetricFlow, or LookML')
|
||||
.option('--source-branch <branch>', 'Git branch for source setup')
|
||||
.option('--source-subpath <path>', 'Repo subpath for source setup')
|
||||
.option('--source-auth-token-ref <ref>', 'env: or file: credential ref for source repo auth')
|
||||
.option('--source-url <url>', 'Source service URL for Metabase or Looker')
|
||||
.option('--source-api-key-ref <ref>', 'env: or file: API key ref for Metabase or Notion')
|
||||
.option('--source-client-id <id>', 'Looker client id')
|
||||
.option('--source-client-secret-ref <ref>', 'env: or file: Looker client secret ref')
|
||||
.option('--source-warehouse-connection-id <id>', 'Mapped warehouse connection id')
|
||||
.option('--source-project-name <name>', 'dbt project name override')
|
||||
.option('--source-profiles-path <path>', 'dbt profiles path')
|
||||
.option('--source-target <target>', 'dbt target or source-specific mapping target')
|
||||
.option('--metabase-database-id <id>', 'Metabase database id to map', positiveNumber)
|
||||
.addOption(
|
||||
new Option('--notion-crawl-mode <mode>', 'Notion crawl mode').choices(['all_accessible', 'selected_roots']),
|
||||
)
|
||||
.option(
|
||||
'--notion-root-page-id <id>',
|
||||
'Notion root page id; repeatable',
|
||||
(value, previous: string[]) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.option('--skip-initial-source-ingest', 'Validate source setup without building source context during setup', false)
|
||||
.option('--skip-sources', 'Mark optional source setup complete with no sources', false)
|
||||
.showHelpAfterError();
|
||||
|
||||
setup.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('setup', actionCommand);
|
||||
});
|
||||
|
||||
setup.action(async (options, command) => {
|
||||
if (options.anthropicApiKeyEnv && options.anthropicApiKeyFile) {
|
||||
context.io.stderr.write(
|
||||
'Choose only one Anthropic credential source: --anthropic-api-key-env or --anthropic-api-key-file.\n',
|
||||
);
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.embeddingApiKeyEnv && options.embeddingApiKeyFile) {
|
||||
context.io.stderr.write(
|
||||
'Choose only one embedding credential source: --embedding-api-key-env or --embedding-api-key-file.\n',
|
||||
);
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.enableHistoricSql && options.disableHistoricSql) {
|
||||
context.io.stderr.write(
|
||||
'Choose only one Historic SQL action: --enable-historic-sql or --disable-historic-sql.\n',
|
||||
);
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.sourcePath && options.sourceGitUrl) {
|
||||
context.io.stderr.write('Choose only one source location: --source-path or --source-git-url.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
if (options.skipSources && options.source) {
|
||||
context.io.stderr.write('Choose either --source or --skip-sources.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto';
|
||||
const resolvedAgentScope = options.global ? 'global' : options.agentScope;
|
||||
await runSetupArgs(context, {
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
mode,
|
||||
agents: options.agents === true,
|
||||
...(options.target ? { target: options.target } : {}),
|
||||
agentScope: resolvedAgentScope,
|
||||
agentInstallMode: options.agentInstallMode,
|
||||
skipAgents: options.skipAgents === true,
|
||||
inputMode: options.input === false ? 'disabled' : 'auto',
|
||||
yes: options.yes === true,
|
||||
...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
|
||||
...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
|
||||
...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
|
||||
skipLlm: options.skipLlm === true,
|
||||
...(options.embeddingBackend ? { embeddingBackend: options.embeddingBackend } : {}),
|
||||
...(options.embeddingApiKeyEnv ? { embeddingApiKeyEnv: options.embeddingApiKeyEnv } : {}),
|
||||
...(options.embeddingApiKeyFile ? { embeddingApiKeyFile: options.embeddingApiKeyFile } : {}),
|
||||
skipEmbeddings: options.skipEmbeddings === true,
|
||||
...(options.database.length > 0 ? { databaseDrivers: options.database } : {}),
|
||||
...(options.databaseConnectionId.length > 0 ? { databaseConnectionIds: options.databaseConnectionId } : {}),
|
||||
...(options.newDatabaseConnectionId ? { databaseConnectionId: options.newDatabaseConnectionId } : {}),
|
||||
...(options.databaseUrl ? { databaseUrl: options.databaseUrl } : {}),
|
||||
databaseSchemas: options.databaseSchema,
|
||||
...(options.enableHistoricSql ? { enableHistoricSql: true } : {}),
|
||||
...(options.disableHistoricSql ? { disableHistoricSql: true } : {}),
|
||||
...(options.historicSqlWindowDays !== undefined ? { historicSqlWindowDays: options.historicSqlWindowDays } : {}),
|
||||
...(options.historicSqlMinCalls !== undefined ? { historicSqlMinCalls: options.historicSqlMinCalls } : {}),
|
||||
...(options.historicSqlServiceAccountPattern.length > 0
|
||||
? { historicSqlServiceAccountPatterns: options.historicSqlServiceAccountPattern }
|
||||
: {}),
|
||||
...(options.historicSqlRedactionPattern.length > 0
|
||||
? { historicSqlRedactionPatterns: options.historicSqlRedactionPattern }
|
||||
: {}),
|
||||
skipDatabases: options.skipDatabases === true,
|
||||
...(options.source ? { source: options.source } : {}),
|
||||
...(options.sourceConnectionId ? { sourceConnectionId: options.sourceConnectionId } : {}),
|
||||
...(options.sourcePath ? { sourcePath: options.sourcePath } : {}),
|
||||
...(options.sourceGitUrl ? { sourceGitUrl: options.sourceGitUrl } : {}),
|
||||
...(options.sourceBranch ? { sourceBranch: options.sourceBranch } : {}),
|
||||
...(options.sourceSubpath ? { sourceSubpath: options.sourceSubpath } : {}),
|
||||
...(options.sourceAuthTokenRef ? { sourceAuthTokenRef: options.sourceAuthTokenRef } : {}),
|
||||
...(options.sourceUrl ? { sourceUrl: options.sourceUrl } : {}),
|
||||
...(options.sourceApiKeyRef ? { sourceApiKeyRef: options.sourceApiKeyRef } : {}),
|
||||
...(options.sourceClientId ? { sourceClientId: options.sourceClientId } : {}),
|
||||
...(options.sourceClientSecretRef ? { sourceClientSecretRef: options.sourceClientSecretRef } : {}),
|
||||
...(options.sourceWarehouseConnectionId
|
||||
? { sourceWarehouseConnectionId: options.sourceWarehouseConnectionId }
|
||||
: {}),
|
||||
...(options.sourceProjectName ? { sourceProjectName: options.sourceProjectName } : {}),
|
||||
...(options.sourceProfilesPath ? { sourceProfilesPath: options.sourceProfilesPath } : {}),
|
||||
...(options.sourceTarget ? { sourceTarget: options.sourceTarget } : {}),
|
||||
...(options.metabaseDatabaseId !== undefined ? { metabaseDatabaseId: options.metabaseDatabaseId } : {}),
|
||||
...(options.notionCrawlMode ? { notionCrawlMode: options.notionCrawlMode } : {}),
|
||||
...(options.notionRootPageId.length > 0 ? { notionRootPageIds: options.notionRootPageId } : {}),
|
||||
runInitialSourceIngest: false,
|
||||
skipSources: options.skipSources === true,
|
||||
showEntryMenu: shouldShowSetupEntryMenu(options, command),
|
||||
});
|
||||
});
|
||||
|
||||
registerDemoCommands(setup, context, { description: 'Run the packaged KLO demo from setup' });
|
||||
|
||||
const setupContext = setup.command('context').description('Build, inspect, and recover setup-managed KLO context');
|
||||
|
||||
function setupContextInputMode(command: {
|
||||
optsWithGlobals?: () => unknown;
|
||||
opts?: () => unknown;
|
||||
}): 'auto' | 'disabled' {
|
||||
const options = command.optsWithGlobals?.() as { input?: boolean } | undefined;
|
||||
return options?.input === false ? 'disabled' : 'auto';
|
||||
}
|
||||
|
||||
setupContext
|
||||
.command('build')
|
||||
.description('Build agent-ready KLO context for setup')
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (options: { input?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-build',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
inputMode: options.input === false ? 'disabled' : setupContextInputMode(command),
|
||||
});
|
||||
});
|
||||
|
||||
setupContext
|
||||
.command('watch')
|
||||
.description('Watch a setup-managed context build')
|
||||
.argument('[runId]', 'Setup context build run id')
|
||||
.option('--no-input', 'Disable interactive terminal input')
|
||||
.action(async (runId: string | undefined, options: { input?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-watch',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
inputMode: options.input === false ? 'disabled' : setupContextInputMode(command),
|
||||
});
|
||||
});
|
||||
|
||||
setupContext
|
||||
.command('status')
|
||||
.description('Print setup-managed context build status')
|
||||
.argument('[runId]', 'Setup context build run id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (runId: string | undefined, options: { json?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
|
||||
setupContext
|
||||
.command('stop')
|
||||
.description('Request a pause for a setup-managed context build')
|
||||
.argument('[runId]', 'Setup context build run id')
|
||||
.option('--force', 'Request the pause without an interactive confirmation', false)
|
||||
.action(async (runId: string | undefined, _options: { force?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'context-stop',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(runId ? { runId } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
setup
|
||||
.command('remove')
|
||||
.description('Remove setup-managed local integrations')
|
||||
.option('--agents', 'Remove setup-managed agent integration files', false)
|
||||
.action(async (options: { agents?: boolean }, command) => {
|
||||
const parentOptions = command.parent?.opts() as { agents?: boolean } | undefined;
|
||||
if (options.agents !== true && parentOptions?.agents !== true) {
|
||||
context.io.stderr.write('Choose what to remove: --agents.\n');
|
||||
context.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
await runSetupArgs(context, {
|
||||
command: 'remove-agents',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
});
|
||||
});
|
||||
|
||||
setup
|
||||
.command('status')
|
||||
.description('Show setup readiness for the resolved KLO project')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }, command) => {
|
||||
await runSetupArgs(context, {
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: options.json === true,
|
||||
});
|
||||
});
|
||||
}
|
||||
148
packages/cli/src/commands/sl-commands.ts
Normal file
148
packages/cli/src/commands/sl-commands.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
|
||||
import {
|
||||
collectOption,
|
||||
type KloCliCommandContext,
|
||||
parsePositiveIntegerOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import { slQueryCommandSchema } from '../command-schemas.js';
|
||||
import type { KloSlArgs } from '../sl.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
profileMark('module:commands/sl-commands');
|
||||
|
||||
function parseOrderBy(value: string): string | { field: string; direction?: string } {
|
||||
const [field, direction] = value.split(':');
|
||||
if (!field) {
|
||||
throw new InvalidArgumentError('requires a field');
|
||||
}
|
||||
if (!direction) {
|
||||
return field;
|
||||
}
|
||||
if (direction !== 'asc' && direction !== 'desc') {
|
||||
throw new InvalidArgumentError('direction must be asc or desc');
|
||||
}
|
||||
return { field, direction };
|
||||
}
|
||||
|
||||
function collectOrderBy(
|
||||
value: string,
|
||||
previous: Array<string | { field: string; direction?: string }> = [],
|
||||
): Array<string | { field: string; direction?: string }> {
|
||||
return [...previous, parseOrderBy(value)];
|
||||
}
|
||||
|
||||
async function runSlArgs(context: KloCliCommandContext, args: KloSlArgs): Promise<void> {
|
||||
const runner = context.deps.sl ?? (await import('../sl.js')).runKloSl;
|
||||
context.setExitCode(await runner(args, context.io));
|
||||
}
|
||||
|
||||
export function registerSlCommands(program: Command, context: KloCliCommandContext, commandName = 'sl'): void {
|
||||
const sl = program
|
||||
.command(commandName)
|
||||
.description('List, read, validate, query, or write local semantic-layer sources')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KLO_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
sl.command('list')
|
||||
.description('List semantic-layer sources')
|
||||
.option('--connection-id <id>', 'KLO connection id')
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(async (options: { connectionId?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('read')
|
||||
.description('Read a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.requiredOption('--connection-id <id>', 'KLO connection id')
|
||||
.action(async (sourceName: string, options: { connectionId: string }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
sourceName,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('validate')
|
||||
.description('Validate a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.requiredOption('--connection-id <id>', 'KLO connection id')
|
||||
.action(async (sourceName: string, options: { connectionId: string }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'validate',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
sourceName,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('write')
|
||||
.description('Write a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.requiredOption('--connection-id <id>', 'KLO connection id')
|
||||
.requiredOption('--yaml <yaml>', 'Semantic-layer source YAML')
|
||||
.action(async (sourceName: string, options: { connectionId: string; yaml: string }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'write',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
sourceName,
|
||||
yaml: options.yaml,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('query')
|
||||
.description('Compile or execute a semantic-layer query')
|
||||
.option('--connection-id <id>', 'KLO connection id')
|
||||
.option('--measure <measure>', 'Measure to query; repeatable', collectOption, [])
|
||||
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
|
||||
.option('--filter <filter>', 'Filter expression; repeatable', collectOption, [])
|
||||
.option('--segment <segment>', 'Segment to include; repeatable', collectOption, [])
|
||||
.option('--order-by <field[:direction]>', 'Order field, optionally suffixed with :asc or :desc', collectOrderBy, [])
|
||||
.option('--limit <n>', 'Query limit', parsePositiveIntegerOption)
|
||||
.option('--include-empty', 'Include empty rows', false)
|
||||
.addOption(new Option('--format <format>', 'json or sql').choices(['json', 'sql']).default('json'))
|
||||
.option('--execute', 'Execute the compiled query', false)
|
||||
.option('--max-rows <n>', 'Maximum rows to return when executing', parsePositiveIntegerOption)
|
||||
.action(async (options, command) => {
|
||||
if (options.measure.length === 0) {
|
||||
throw new Error('sl query requires at least one --measure');
|
||||
}
|
||||
const args = slQueryCommandSchema.parse({
|
||||
command: 'query',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
query: {
|
||||
measures: options.measure,
|
||||
dimensions: options.dimension,
|
||||
...(options.filter.length > 0 ? { filters: options.filter } : {}),
|
||||
...(options.segment.length > 0 ? { segments: options.segment } : {}),
|
||||
...(options.orderBy.length > 0 ? { order_by: options.orderBy } : {}),
|
||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
...(options.includeEmpty === true ? { include_empty: true } : {}),
|
||||
},
|
||||
format: options.format,
|
||||
execute: options.execute === true,
|
||||
...(options.maxRows !== undefined ? { maxRows: options.maxRows } : {}),
|
||||
});
|
||||
await runSlArgs(context, args);
|
||||
});
|
||||
}
|
||||
23
packages/cli/src/commands/status-commands.ts
Normal file
23
packages/cli/src/commands/status-commands.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Command } from '@commander-js/extra-typings';
|
||||
import type { KloCliCommandContext } from '../cli-program.js';
|
||||
import { resolveCommandProjectDir } from '../cli-program.js';
|
||||
|
||||
export function registerStatusCommands(program: Command, context: KloCliCommandContext): void {
|
||||
program
|
||||
.command('status')
|
||||
.description('Show current KLO project setup status')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (options: { json?: boolean }, command) => {
|
||||
const runner = context.deps.setup ?? (await import('../setup.js')).runKloSetup;
|
||||
context.setExitCode(
|
||||
await runner(
|
||||
{
|
||||
command: 'status',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
json: options.json === true,
|
||||
},
|
||||
context.io,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
353
packages/cli/src/completion.ts
Normal file
353
packages/cli/src/completion.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { CommandUnknownOpts, Option } from '@commander-js/extra-typings';
|
||||
|
||||
export interface CompletionRequest {
|
||||
position: number;
|
||||
words: string[];
|
||||
}
|
||||
|
||||
interface CompletionCandidate {
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CommandWithHiddenFlag extends CommandUnknownOpts {
|
||||
_hidden?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveState {
|
||||
command: CommandUnknownOpts;
|
||||
pendingOption?: Option;
|
||||
positionalIndex: number;
|
||||
}
|
||||
|
||||
export interface ZshCompletionInstallResult {
|
||||
completionPath: string;
|
||||
zshrcPath: string;
|
||||
}
|
||||
|
||||
const KLO_COMPLETION_BLOCK_START = '# >>> klo completion >>>';
|
||||
const KLO_COMPLETION_BLOCK_END = '# <<< klo completion <<<';
|
||||
const KLO_COMPLETION_BLOCK_PATTERN = new RegExp(
|
||||
`\\n?${escapeRegExp(KLO_COMPLETION_BLOCK_START)}[\\s\\S]*?${escapeRegExp(KLO_COMPLETION_BLOCK_END)}\\n?`,
|
||||
'g',
|
||||
);
|
||||
|
||||
export function zshCompletionScript(): string {
|
||||
const zshWords = '$' + '{words[@]}';
|
||||
const zshCompletionCapture = [
|
||||
'$',
|
||||
`{(@f)$("${'$'}{klo_completion_command[@]}" dev __complete --shell zsh --position "$CURRENT" -- "${zshWords}" 2>/dev/null)}`,
|
||||
].join('');
|
||||
const zshCompletionsCount = '$' + '{#completions[@]}';
|
||||
const zshCompletionCommand = '$' + '(eval "print -r -- $' + '{KLO_COMPLETION_COMMAND:-klo}")';
|
||||
|
||||
return [
|
||||
'#compdef klo',
|
||||
'',
|
||||
'_klo() {',
|
||||
' local -a completions',
|
||||
' local -a klo_completion_command',
|
||||
` klo_completion_command=("\${(@z)${zshCompletionCommand}}")`,
|
||||
` completions=("${zshCompletionCapture}")`,
|
||||
` if (( ${zshCompletionsCount} )); then`,
|
||||
" _describe 'klo completions' completions",
|
||||
' else',
|
||||
' _files',
|
||||
' fi',
|
||||
'}',
|
||||
'',
|
||||
'compdef _klo klo',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function installZshCompletion(): Promise<ZshCompletionInstallResult> {
|
||||
const homeDir = process.env.HOME || homedir();
|
||||
const zshConfigDir = process.env.ZDOTDIR || homeDir;
|
||||
const completionDir = join(homeDir, '.zfunc');
|
||||
const completionPath = join(completionDir, '_klo');
|
||||
const zshrcPath = join(zshConfigDir, '.zshrc');
|
||||
|
||||
await mkdir(completionDir, { recursive: true });
|
||||
await mkdir(dirname(zshrcPath), { recursive: true });
|
||||
await writeFile(completionPath, zshCompletionScript(), 'utf-8');
|
||||
|
||||
const existingZshrc = await readOptionalTextFile(zshrcPath);
|
||||
const nextZshrc = updateZshrcCompletionBlock(existingZshrc);
|
||||
await writeFile(zshrcPath, nextZshrc, 'utf-8');
|
||||
|
||||
return { completionPath, zshrcPath };
|
||||
}
|
||||
|
||||
export function completeCommanderInput(program: CommandUnknownOpts, request: CompletionRequest): string[] {
|
||||
const words = completionWordsForPosition(request.words, request.position);
|
||||
const tokens = stripProgramName(program, words);
|
||||
const current = tokens.at(-1) ?? '';
|
||||
const previous = tokens.slice(0, -1);
|
||||
const state = resolveCommandState(program, previous);
|
||||
|
||||
return candidatesForState(state, current).map(formatZshCandidate);
|
||||
}
|
||||
|
||||
function completionWordsForPosition(words: string[], position: number): string[] {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
return words;
|
||||
}
|
||||
return words.slice(0, position);
|
||||
}
|
||||
|
||||
function stripProgramName(program: CommandUnknownOpts, words: string[]): string[] {
|
||||
const [first, ...rest] = words;
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
return first === program.name() || first.endsWith(`/${program.name()}`) ? rest : words;
|
||||
}
|
||||
|
||||
function resolveCommandState(program: CommandUnknownOpts, tokens: string[]): ResolveState {
|
||||
let command = program;
|
||||
let positionalIndex = 0;
|
||||
let pendingOption: Option | undefined;
|
||||
let positionalOnly = false;
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (pendingOption) {
|
||||
pendingOption = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--') {
|
||||
positionalOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!positionalOnly && token.startsWith('-')) {
|
||||
const option = findOption(command, optionNameFromToken(token));
|
||||
if (option && !token.includes('=') && optionTakesValue(option)) {
|
||||
if (index === tokens.length - 1) {
|
||||
pendingOption = option;
|
||||
} else if (option.required || !tokens[index + 1]?.startsWith('-')) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = findVisibleSubcommand(command, token);
|
||||
if (child) {
|
||||
command = child;
|
||||
positionalIndex = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
positionalIndex += 1;
|
||||
}
|
||||
|
||||
return { command, pendingOption, positionalIndex };
|
||||
}
|
||||
|
||||
function candidatesForState(state: ResolveState, current: string): CompletionCandidate[] {
|
||||
const optionValue = splitOptionValueToken(current);
|
||||
if (optionValue) {
|
||||
const option = findOption(state.command, optionValue.optionName);
|
||||
return choiceCandidates(option?.argChoices, optionValue.valuePrefix, optionValue.optionPrefix);
|
||||
}
|
||||
|
||||
if (state.pendingOption) {
|
||||
return choiceCandidates(state.pendingOption.argChoices, current);
|
||||
}
|
||||
|
||||
if (current.startsWith('-')) {
|
||||
return visibleOptions(state.command)
|
||||
.map(optionCandidate)
|
||||
.filter((candidate) => candidate.value.startsWith(current));
|
||||
}
|
||||
|
||||
const commandCandidates = visibleSubcommands(state.command)
|
||||
.map(commandCandidate)
|
||||
.filter((candidate) => candidate.value.startsWith(current));
|
||||
const argument = state.command.registeredArguments[state.positionalIndex];
|
||||
return [...commandCandidates, ...choiceCandidates(argument?.argChoices, current)];
|
||||
}
|
||||
|
||||
function visibleSubcommands(command: CommandUnknownOpts): CommandUnknownOpts[] {
|
||||
return command.commands.filter((subcommand) => (subcommand as CommandWithHiddenFlag)._hidden !== true);
|
||||
}
|
||||
|
||||
function findVisibleSubcommand(command: CommandUnknownOpts, name: string): CommandUnknownOpts | undefined {
|
||||
return visibleSubcommands(command).find(
|
||||
(subcommand) => subcommand.name() === name || subcommand.aliases().includes(name),
|
||||
);
|
||||
}
|
||||
|
||||
function visibleOptions(command: CommandUnknownOpts): Option[] {
|
||||
const options: Option[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const current of commandChain(command)) {
|
||||
for (const option of current.options) {
|
||||
if (option.hidden) {
|
||||
continue;
|
||||
}
|
||||
const key = option.long ?? option.short ?? option.flags;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push(option);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function commandChain(command: CommandUnknownOpts): CommandUnknownOpts[] {
|
||||
const chain: CommandUnknownOpts[] = [];
|
||||
let current: CommandUnknownOpts | null = command;
|
||||
while (current) {
|
||||
chain.unshift(current);
|
||||
current = current.parent;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function findOption(command: CommandUnknownOpts, name: string): Option | undefined {
|
||||
return visibleOptions(command).find((option) => option.long === name || option.short === name);
|
||||
}
|
||||
|
||||
function optionTakesValue(option: Option): boolean {
|
||||
return option.required || option.optional;
|
||||
}
|
||||
|
||||
function optionNameFromToken(token: string): string {
|
||||
return token.split('=', 1)[0] ?? token;
|
||||
}
|
||||
|
||||
function splitOptionValueToken(
|
||||
token: string,
|
||||
): { optionName: string; optionPrefix: string; valuePrefix: string } | null {
|
||||
const separatorIndex = token.indexOf('=');
|
||||
if (!token.startsWith('-') || separatorIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
optionName: token.slice(0, separatorIndex),
|
||||
optionPrefix: token.slice(0, separatorIndex + 1),
|
||||
valuePrefix: token.slice(separatorIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function commandCandidate(command: CommandUnknownOpts): CompletionCandidate {
|
||||
return {
|
||||
value: command.name(),
|
||||
description: command.summary() || command.description(),
|
||||
};
|
||||
}
|
||||
|
||||
function optionCandidate(option: Option): CompletionCandidate {
|
||||
return {
|
||||
value: option.long ?? option.short ?? option.flags,
|
||||
description: option.description,
|
||||
};
|
||||
}
|
||||
|
||||
function choiceCandidates(
|
||||
choices: readonly string[] | undefined,
|
||||
prefix: string,
|
||||
completionPrefix = '',
|
||||
): CompletionCandidate[] {
|
||||
return (choices ?? [])
|
||||
.filter((choice) => choice.startsWith(prefix))
|
||||
.map((choice) => ({ value: `${completionPrefix}${choice}` }));
|
||||
}
|
||||
|
||||
function formatZshCandidate(candidate: CompletionCandidate): string {
|
||||
if (!candidate.description) {
|
||||
return escapeZshCompletion(candidate.value);
|
||||
}
|
||||
return `${escapeZshCompletion(candidate.value)}:${escapeZshDescription(candidate.description)}`;
|
||||
}
|
||||
|
||||
function escapeZshCompletion(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
|
||||
}
|
||||
|
||||
function escapeZshDescription(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\\/g, '\\\\').replace(/:/g, '\\:').trim();
|
||||
}
|
||||
|
||||
async function readOptionalTextFile(path: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(path, 'utf-8');
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function updateZshrcCompletionBlock(contents: string): string {
|
||||
const withoutManagedBlock = contents.replace(KLO_COMPLETION_BLOCK_PATTERN, normalizeTrailingNewline);
|
||||
const hasCompinit = /^.*\bcompinit\b.*$/m.test(withoutManagedBlock);
|
||||
const block = zshrcCompletionBlock({ includeCompinit: !hasCompinit });
|
||||
|
||||
if (!hasCompinit) {
|
||||
return appendBlock(withoutManagedBlock, block);
|
||||
}
|
||||
|
||||
const compinitMatch = /^.*\bcompinit\b.*$/m.exec(withoutManagedBlock);
|
||||
if (!compinitMatch || compinitMatch.index === undefined) {
|
||||
return appendBlock(withoutManagedBlock, block);
|
||||
}
|
||||
|
||||
return [
|
||||
withoutManagedBlock.slice(0, compinitMatch.index),
|
||||
block,
|
||||
'\n',
|
||||
withoutManagedBlock.slice(compinitMatch.index),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function zshrcCompletionBlock(options: { includeCompinit: boolean }): string {
|
||||
return [
|
||||
KLO_COMPLETION_BLOCK_START,
|
||||
'_klo_completion_command() {',
|
||||
' local dir="$PWD"',
|
||||
' while [[ "$dir" != "/" ]]; do',
|
||||
` if [[ -f "$dir/package.json" ]] && command grep -q '"name": "klo-workspace"' "$dir/package.json" 2>/dev/null; then`,
|
||||
' print -r -- "node $dir/scripts/run-klo.mjs --"',
|
||||
' return',
|
||||
' fi',
|
||||
' dir="' + '$' + '{dir:h}"',
|
||||
' done',
|
||||
' print -r -- "klo"',
|
||||
'}',
|
||||
"export KLO_COMPLETION_COMMAND='$(_klo_completion_command)'",
|
||||
'setopt complete_aliases',
|
||||
'fpath=("$HOME/.zfunc" $fpath)',
|
||||
...(options.includeCompinit ? ['autoload -Uz compinit', 'compinit'] : []),
|
||||
KLO_COMPLETION_BLOCK_END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function appendBlock(contents: string, block: string): string {
|
||||
if (!contents.trim()) {
|
||||
return `${block}\n`;
|
||||
}
|
||||
return `${contents.replace(/\s*$/, '\n\n')}${block}\n`;
|
||||
}
|
||||
|
||||
function normalizeTrailingNewline(match: string): string {
|
||||
return match.startsWith('\n') || match.endsWith('\n') ? '\n' : '';
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error;
|
||||
}
|
||||
649
packages/cli/src/connection.test.ts
Normal file
649
packages/cli/src/connection.test.ts
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKloProject, parseKloProjectConfig } from '@klo/context/project';
|
||||
import type { KloConnectionDriver, KloScanConnector, KloSchemaSnapshot } from '@klo/context/scan';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKloConnection } from './connection.js';
|
||||
import { runKloCli, type KloCliIo } from './index.js';
|
||||
|
||||
function makeIo(options: { stdoutIsTty?: boolean; stdinIsTty?: boolean } = {}) {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdin: {
|
||||
isTTY: options.stdinIsTty,
|
||||
},
|
||||
stdout: {
|
||||
isTTY: options.stdoutIsTty,
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotFor(driver: KloConnectionDriver, tableNames: string[]): KloSchemaSnapshot {
|
||||
return {
|
||||
connectionId: 'warehouse',
|
||||
driver,
|
||||
extractedAt: '2026-04-29T00:00:00.000Z',
|
||||
scope: {},
|
||||
metadata: {},
|
||||
tables: tableNames.map((name) => ({
|
||||
catalog: null,
|
||||
db: null,
|
||||
name,
|
||||
kind: 'table',
|
||||
comment: null,
|
||||
estimatedRows: null,
|
||||
columns: [],
|
||||
foreignKeys: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function nativeConnector(driver: KloConnectionDriver, tableNames: string[]) {
|
||||
const introspect = vi.fn(async () => snapshotFor(driver, tableNames));
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
const connector: KloScanConnector = {
|
||||
id: `${driver}:warehouse`,
|
||||
driver,
|
||||
capabilities: {
|
||||
structuralIntrospection: true,
|
||||
tableSampling: false,
|
||||
columnSampling: false,
|
||||
columnStats: false,
|
||||
readOnlySql: false,
|
||||
nestedAnalysis: false,
|
||||
eventStreamDiscovery: false,
|
||||
formalForeignKeys: false,
|
||||
estimatedRowCounts: false,
|
||||
},
|
||||
introspect,
|
||||
cleanup,
|
||||
};
|
||||
return { connector, introspect, cleanup };
|
||||
}
|
||||
|
||||
describe('runKloConnection', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-cli-connection-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('adds and lists env-referenced connections without resolving secrets', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'postgres',
|
||||
connectionId: 'warehouse',
|
||||
url: 'env:DATABASE_URL',
|
||||
schemas: ['public'],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stdout()).toContain('Connection: warehouse');
|
||||
await expect(readFile(join(projectDir, 'klo.yaml'), 'utf-8')).resolves.toContain('url: env:DATABASE_URL');
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKloConnection({ command: 'list', projectDir }, listIo.io)).resolves.toBe(0);
|
||||
expect(listIo.stdout()).toContain('warehouse');
|
||||
expect(listIo.stdout()).toContain('postgres');
|
||||
});
|
||||
|
||||
it('removes a configured connection from klo.yaml without deleting local artifacts when forced', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'sqlite',
|
||||
connectionId: 'warehouse',
|
||||
url: undefined,
|
||||
schemas: [],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
},
|
||||
makeIo().io,
|
||||
);
|
||||
const artifactPath = join(projectDir, '.klo', 'artifacts', 'warehouse.txt');
|
||||
await mkdir(join(projectDir, '.klo', 'artifacts'), { recursive: true });
|
||||
await writeFile(artifactPath, 'keep me', 'utf-8');
|
||||
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'remove',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
force: true,
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const parsed = parseKloProjectConfig(await readFile(join(projectDir, 'klo.yaml'), 'utf-8'));
|
||||
expect(parsed.connections.warehouse).toBeUndefined();
|
||||
await expect(readFile(artifactPath, 'utf-8')).resolves.toBe('keep me');
|
||||
expect(io.stdout()).toContain('Connection removed from klo.yaml.');
|
||||
expect(io.stdout()).toContain(
|
||||
'Ingested artifacts from this connection remain in .klo/. Run klo dev artifacts to inspect.',
|
||||
);
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('requires --force when removing in non-interactive mode', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'sqlite',
|
||||
connectionId: 'warehouse',
|
||||
url: undefined,
|
||||
schemas: [],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
},
|
||||
makeIo().io,
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'remove',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
force: false,
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('connection remove warehouse requires --force when input is disabled or not interactive');
|
||||
});
|
||||
|
||||
it('returns a clear error when removing an unknown connection', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'remove',
|
||||
projectDir,
|
||||
connectionId: 'missing',
|
||||
force: true,
|
||||
inputMode: 'disabled',
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('Connection "missing" is not configured in klo.yaml');
|
||||
});
|
||||
|
||||
it('asks for confirmation before removing in an interactive terminal', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'sqlite',
|
||||
connectionId: 'warehouse',
|
||||
url: undefined,
|
||||
schemas: [],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
},
|
||||
makeIo().io,
|
||||
);
|
||||
const io = makeIo({ stdoutIsTty: true, stdinIsTty: true });
|
||||
const prompts = {
|
||||
confirm: vi.fn(async () => true),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'remove',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
force: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(prompts.confirm).toHaveBeenCalledWith({
|
||||
message: 'Remove connection "warehouse" from klo.yaml? Ingested artifacts will remain in .klo/.',
|
||||
initialValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('runs public connect map as refresh, validate, and list over the low-level mapping runner', async () => {
|
||||
const io = makeIo();
|
||||
const runMapping = vi.fn(async (argv: string[], mappingIo: KloCliIo) => {
|
||||
if (argv[0] === 'refresh') {
|
||||
mappingIo.stdout.write('Discovery: 1 database\n');
|
||||
mappingIo.stdout.write('Unmapped discovered: 1\n');
|
||||
mappingIo.stdout.write('Stale mappings: 0\n');
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === 'validate') {
|
||||
mappingIo.stdout.write('Mapping validation passed: prod-metabase\n');
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === 'list') {
|
||||
mappingIo.stdout.write('1 -> [unmapped] (Analytics, sync: on, source: refresh)\n');
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{ command: 'map', projectDir: '/tmp/project', sourceConnectionId: 'prod-metabase', json: false },
|
||||
io.io,
|
||||
{ runMapping },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(runMapping).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['refresh', 'prod-metabase', '--auto-accept', '--project-dir', '/tmp/project'],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(runMapping).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['validate', 'prod-metabase', '--project-dir', '/tmp/project'],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(runMapping).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['list', 'prod-metabase', '--project-dir', '/tmp/project'],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(io.stdout()).toContain('Mapping: prod-metabase');
|
||||
expect(io.stdout()).toContain('Discovery: 1 database');
|
||||
expect(io.stdout()).toContain('Mappings:');
|
||||
expect(io.stdout()).toContain('1 -> [unmapped]');
|
||||
expect(io.stdout()).toContain('Next:');
|
||||
expect(io.stdout()).toContain('klo ingest prod-metabase');
|
||||
expect(io.stdout()).toContain('klo dev mapping');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('prints stable JSON for public connect map without leaking low-level stdout', async () => {
|
||||
const io = makeIo();
|
||||
const runMapping = vi.fn(async (argv: string[], mappingIo: KloCliIo) => {
|
||||
if (argv[0] === 'refresh') {
|
||||
mappingIo.stdout.write('Discovery: 1 connection\nUnmapped discovered: 0\nStale mappings: 0\n');
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === 'validate') {
|
||||
mappingIo.stdout.write('Mapping validation passed: prod-looker\n');
|
||||
return 0;
|
||||
}
|
||||
if (argv[0] === 'list') {
|
||||
expect(argv).toContain('--json');
|
||||
mappingIo.stdout.write(
|
||||
`${JSON.stringify(
|
||||
[
|
||||
{
|
||||
lookerConnectionName: 'analytics',
|
||||
kloConnectionId: 'prod-warehouse',
|
||||
source: 'klo.yaml',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{ command: 'map', projectDir: '/tmp/project', sourceConnectionId: 'prod-looker', json: true },
|
||||
io.io,
|
||||
{ runMapping },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const parsed = JSON.parse(io.stdout()) as {
|
||||
connectionId: string;
|
||||
refresh: { ok: boolean; output: string[] };
|
||||
validation: { ok: boolean; output: string[] };
|
||||
mappings: Array<{ lookerConnectionName: string; kloConnectionId: string; source: string }>;
|
||||
};
|
||||
expect(parsed).toEqual({
|
||||
connectionId: 'prod-looker',
|
||||
refresh: {
|
||||
ok: true,
|
||||
output: ['Discovery: 1 connection', 'Unmapped discovered: 0', 'Stale mappings: 0'],
|
||||
},
|
||||
validation: {
|
||||
ok: true,
|
||||
output: ['Mapping validation passed: prod-looker'],
|
||||
},
|
||||
mappings: [
|
||||
{
|
||||
lookerConnectionName: 'analytics',
|
||||
kloConnectionId: 'prod-warehouse',
|
||||
source: 'klo.yaml',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('returns the refresh failure when public connect map cannot discover source metadata', async () => {
|
||||
const io = makeIo();
|
||||
const runMapping = vi.fn(async (argv: string[], mappingIo: KloCliIo) => {
|
||||
if (argv[0] === 'refresh') {
|
||||
mappingIo.stderr.write('Metabase API key is not configured\n');
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{ command: 'map', projectDir: '/tmp/project', sourceConnectionId: 'prod-metabase', json: false },
|
||||
io.io,
|
||||
{ runMapping },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(runMapping).toHaveBeenCalledTimes(1);
|
||||
expect(io.stdout()).toBe('');
|
||||
expect(io.stderr()).toContain('Metabase API key is not configured');
|
||||
});
|
||||
|
||||
it('rejects literal credential URLs unless explicitly allowed', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'postgres',
|
||||
connectionId: 'warehouse',
|
||||
url: 'postgres://localhost:5432/warehouse',
|
||||
schemas: [],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toContain('Literal credential URLs require --allow-literal-credentials');
|
||||
});
|
||||
|
||||
it('warns before writing explicitly allowed literal credential URLs without echoing the URL', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
const io = makeIo();
|
||||
const literalUrl = 'postgres://localhost:5432/warehouse';
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'postgres',
|
||||
connectionId: 'warehouse',
|
||||
url: literalUrl,
|
||||
schemas: ['public'],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: true,
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(io.stderr()).toContain(
|
||||
'Warning: writing a literal credential URL to klo.yaml for connection "warehouse". Prefer env:NAME or file:/path references.',
|
||||
);
|
||||
expect(io.stderr()).not.toContain(literalUrl);
|
||||
await expect(readFile(join(projectDir, 'klo.yaml'), 'utf-8')).resolves.toContain(literalUrl);
|
||||
});
|
||||
|
||||
it('adds a Notion connection without writing token values', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'notion',
|
||||
connectionId: 'notion-main',
|
||||
url: undefined,
|
||||
schemas: [],
|
||||
readonly: false,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
notion: {
|
||||
authTokenRef: 'env:NOTION_AUTH_TOKEN',
|
||||
crawlMode: 'all_accessible',
|
||||
rootPageIds: [],
|
||||
rootDatabaseIds: [],
|
||||
rootDataSourceIds: [],
|
||||
maxPagesPerRun: 50,
|
||||
maxKnowledgeCreatesPerRun: 4,
|
||||
maxKnowledgeUpdatesPerRun: 12,
|
||||
},
|
||||
},
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const yaml = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(yaml).toContain('driver: notion');
|
||||
expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN');
|
||||
expect(yaml).toContain('crawl_mode: all_accessible');
|
||||
expect(yaml).toContain('max_pages_per_run: 50');
|
||||
expect(yaml).not.toContain('ntn_');
|
||||
expect(io.stdout()).toContain('Connection: notion-main');
|
||||
expect(io.stdout()).toContain('Driver: notion');
|
||||
});
|
||||
|
||||
it('runs connection notion pick --no-input through the public connection entrypoint', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'notion',
|
||||
connectionId: 'notion-main',
|
||||
url: undefined,
|
||||
schemas: [],
|
||||
readonly: false,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
notion: {
|
||||
authTokenRef: 'env:NOTION_AUTH_TOKEN',
|
||||
crawlMode: 'all_accessible',
|
||||
rootPageIds: [],
|
||||
rootDatabaseIds: ['database-1'],
|
||||
rootDataSourceIds: ['data-source-1'],
|
||||
maxPagesPerRun: 50,
|
||||
maxKnowledgeCreatesPerRun: 4,
|
||||
maxKnowledgeUpdatesPerRun: 12,
|
||||
},
|
||||
},
|
||||
makeIo().io,
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloCli(
|
||||
[
|
||||
'connection',
|
||||
'notion',
|
||||
'pick',
|
||||
'notion-main',
|
||||
'--project-dir',
|
||||
projectDir,
|
||||
'--no-input',
|
||||
'--root-page-id',
|
||||
'11111111222233334444555555555555',
|
||||
],
|
||||
io.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const yaml = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(yaml).toContain('crawl_mode: selected_roots');
|
||||
expect(yaml).toContain('11111111-2222-3333-4444-555555555555');
|
||||
expect(yaml).toContain('database-1');
|
||||
expect(yaml).toContain('data-source-1');
|
||||
expect(io.stdout()).toContain('Connection: notion-main');
|
||||
});
|
||||
|
||||
it('tests a configured connection through the native scan connector', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'sqlite',
|
||||
connectionId: 'warehouse',
|
||||
url: undefined,
|
||||
schemas: [],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
},
|
||||
makeIo().io,
|
||||
);
|
||||
const { connector, introspect, cleanup } = nativeConnector('sqlite', ['customers', 'orders']);
|
||||
const createScanConnector = vi.fn(async () => connector);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
|
||||
createScanConnector,
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createScanConnector).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'warehouse');
|
||||
expect(introspect).toHaveBeenCalledWith(
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
driver: 'sqlite',
|
||||
mode: 'structural',
|
||||
dryRun: true,
|
||||
detectRelationships: false,
|
||||
},
|
||||
{ runId: 'connection-test-warehouse' },
|
||||
);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(io.stdout()).toContain('Connection test passed: warehouse');
|
||||
expect(io.stdout()).toContain('Driver: sqlite');
|
||||
expect(io.stdout()).toContain('Tables: 2');
|
||||
});
|
||||
|
||||
it('cleans up the native scan connector when connection testing fails', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKloProject({ projectDir, projectName: 'warehouse' });
|
||||
await runKloConnection(
|
||||
{
|
||||
command: 'add',
|
||||
projectDir,
|
||||
driver: 'sqlite',
|
||||
connectionId: 'warehouse',
|
||||
url: undefined,
|
||||
schemas: [],
|
||||
readonly: true,
|
||||
force: false,
|
||||
allowLiteralCredentials: false,
|
||||
},
|
||||
makeIo().io,
|
||||
);
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
const connector: KloScanConnector = {
|
||||
id: 'sqlite:warehouse',
|
||||
driver: 'sqlite',
|
||||
capabilities: {
|
||||
structuralIntrospection: true,
|
||||
tableSampling: false,
|
||||
columnSampling: false,
|
||||
columnStats: false,
|
||||
readOnlySql: false,
|
||||
nestedAnalysis: false,
|
||||
eventStreamDiscovery: false,
|
||||
formalForeignKeys: false,
|
||||
estimatedRowCounts: false,
|
||||
},
|
||||
introspect: vi.fn(async () => {
|
||||
throw new Error('database file is unreadable');
|
||||
}),
|
||||
cleanup,
|
||||
};
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKloConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
|
||||
createScanConnector: vi.fn(async () => connector),
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(io.stderr()).toContain('database file is unreadable');
|
||||
});
|
||||
});
|
||||
415
packages/cli/src/connection.ts
Normal file
415
packages/cli/src/connection.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { cancel, confirm, isCancel } from '@clack/prompts';
|
||||
import { type KloLocalProject, loadKloProject, serializeKloProjectConfig } from '@klo/context/project';
|
||||
import type { KloScanConnector } from '@klo/context/scan';
|
||||
import type { KloConnectionMappingArgs } from './commands/connection-mapping.js';
|
||||
import type { KloCliIo } from './index.js';
|
||||
import { createKloCliScanConnector } from './local-scan-connectors.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:connection');
|
||||
|
||||
interface KloNotionConnectionCliConfig {
|
||||
authTokenRef: string;
|
||||
crawlMode: 'all_accessible' | 'selected_roots';
|
||||
rootPageIds: string[];
|
||||
rootDatabaseIds: string[];
|
||||
rootDataSourceIds: string[];
|
||||
maxPagesPerRun?: number;
|
||||
maxKnowledgeCreatesPerRun?: number;
|
||||
maxKnowledgeUpdatesPerRun?: number;
|
||||
}
|
||||
|
||||
type KloConnectionInputMode = 'disabled';
|
||||
|
||||
export type KloConnectionArgs =
|
||||
| { command: 'list'; projectDir: string }
|
||||
| {
|
||||
command: 'add';
|
||||
projectDir: string;
|
||||
driver: string;
|
||||
connectionId: string;
|
||||
url?: string;
|
||||
schemas: string[];
|
||||
readonly: boolean;
|
||||
force: boolean;
|
||||
allowLiteralCredentials: boolean;
|
||||
notion?: KloNotionConnectionCliConfig;
|
||||
}
|
||||
| { command: 'test'; projectDir: string; connectionId: string }
|
||||
| {
|
||||
command: 'remove';
|
||||
projectDir: string;
|
||||
connectionId: string;
|
||||
force: boolean;
|
||||
inputMode?: KloConnectionInputMode;
|
||||
}
|
||||
| {
|
||||
command: 'map';
|
||||
projectDir: string;
|
||||
sourceConnectionId: string;
|
||||
json: boolean;
|
||||
};
|
||||
|
||||
interface KloConnectionPromptAdapter {
|
||||
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
interface KloConnectionIo extends KloCliIo {
|
||||
stdin?: { isTTY?: boolean };
|
||||
}
|
||||
|
||||
interface KloConnectionDeps {
|
||||
createScanConnector?: typeof createKloCliScanConnector;
|
||||
runMapping?: (argv: string[], io: KloCliIo) => Promise<number>;
|
||||
prompts?: KloConnectionPromptAdapter;
|
||||
}
|
||||
|
||||
function assertSafeConnectionId(connectionId: string): void {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
|
||||
throw new Error(`Unsafe connection id: ${connectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isCredentialReference(value: string): boolean {
|
||||
return value.startsWith('env:') || value.startsWith('file:');
|
||||
}
|
||||
|
||||
function literalCredentialWarning(connectionId: string): string {
|
||||
return `Warning: writing a literal credential URL to klo.yaml for connection "${connectionId}". Prefer env:NAME or file:/path references.`;
|
||||
}
|
||||
|
||||
function createClackConnectionPromptAdapter(): KloConnectionPromptAdapter {
|
||||
return {
|
||||
async confirm(options: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
const value = await confirm(options);
|
||||
return isCancel(value) ? false : value;
|
||||
},
|
||||
cancel(message: string): void {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractiveConnectionIo(
|
||||
args: Extract<KloConnectionArgs, { command: 'remove' }>,
|
||||
io: KloConnectionIo,
|
||||
): boolean {
|
||||
return args.inputMode !== 'disabled' && io.stdin?.isTTY === true && io.stdout.isTTY === true;
|
||||
}
|
||||
|
||||
async function cleanupConnector(connector: KloScanConnector | null): Promise<void> {
|
||||
if (connector?.cleanup) {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function testNativeConnection(
|
||||
project: KloLocalProject,
|
||||
connectionId: string,
|
||||
createScanConnector: typeof createKloCliScanConnector,
|
||||
): Promise<{ driver: string; tableCount: number }> {
|
||||
let connector: KloScanConnector | null = null;
|
||||
try {
|
||||
connector = await createScanConnector(project, connectionId);
|
||||
const snapshot = await connector.introspect(
|
||||
{
|
||||
connectionId,
|
||||
driver: connector.driver,
|
||||
mode: 'structural',
|
||||
dryRun: true,
|
||||
detectRelationships: false,
|
||||
},
|
||||
{ runId: `connection-test-${connectionId}` },
|
||||
);
|
||||
return {
|
||||
driver: connector.driver,
|
||||
tableCount: snapshot.tables.length,
|
||||
};
|
||||
} finally {
|
||||
await cleanupConnector(connector);
|
||||
}
|
||||
}
|
||||
|
||||
interface BufferedIo extends KloCliIo {
|
||||
stdoutText(): string;
|
||||
stderrText(): string;
|
||||
}
|
||||
|
||||
function createBufferedIo(): BufferedIo {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
stdout: {
|
||||
write(chunk: string) {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
stdoutText() {
|
||||
return stdout;
|
||||
},
|
||||
stderrText() {
|
||||
return stderr;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitOutputLines(output: string): string[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function runLowLevelMapping(
|
||||
args: KloConnectionMappingArgs,
|
||||
argv: string[],
|
||||
io: KloCliIo,
|
||||
deps: KloConnectionDeps,
|
||||
): Promise<number> {
|
||||
if (deps.runMapping) {
|
||||
return await deps.runMapping(argv, io);
|
||||
}
|
||||
|
||||
const { runKloConnectionMapping } = await import('./commands/connection-mapping.js');
|
||||
return await runKloConnectionMapping(args, io);
|
||||
}
|
||||
|
||||
function parseMappingListJson(output: string): unknown[] {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
async function runPublicConnectionMap(
|
||||
args: Extract<KloConnectionArgs, { command: 'map' }>,
|
||||
io: KloCliIo,
|
||||
deps: KloConnectionDeps,
|
||||
): Promise<number> {
|
||||
const refreshIo = createBufferedIo();
|
||||
const refreshArgs: KloConnectionMappingArgs = {
|
||||
command: 'refresh',
|
||||
projectDir: args.projectDir,
|
||||
connectionId: args.sourceConnectionId,
|
||||
autoAccept: true,
|
||||
};
|
||||
const refreshCode = await runLowLevelMapping(
|
||||
refreshArgs,
|
||||
['refresh', args.sourceConnectionId, '--auto-accept', '--project-dir', args.projectDir],
|
||||
refreshIo,
|
||||
deps,
|
||||
);
|
||||
if (refreshCode !== 0) {
|
||||
io.stderr.write(
|
||||
refreshIo.stderrText() ||
|
||||
refreshIo.stdoutText() ||
|
||||
`Failed to refresh mapping metadata for ${args.sourceConnectionId}\n`,
|
||||
);
|
||||
return refreshCode;
|
||||
}
|
||||
|
||||
const validationIo = createBufferedIo();
|
||||
const validationArgs: KloConnectionMappingArgs = {
|
||||
command: 'validate',
|
||||
projectDir: args.projectDir,
|
||||
connectionId: args.sourceConnectionId,
|
||||
};
|
||||
const validationCode = await runLowLevelMapping(
|
||||
validationArgs,
|
||||
['validate', args.sourceConnectionId, '--project-dir', args.projectDir],
|
||||
validationIo,
|
||||
deps,
|
||||
);
|
||||
if (validationCode !== 0) {
|
||||
io.stderr.write(
|
||||
validationIo.stderrText() || validationIo.stdoutText() || `Mapping validation failed for ${args.sourceConnectionId}\n`,
|
||||
);
|
||||
return validationCode;
|
||||
}
|
||||
|
||||
const listIo = createBufferedIo();
|
||||
const listArgv = ['list', args.sourceConnectionId, '--project-dir', args.projectDir];
|
||||
const listArgs: KloConnectionMappingArgs = {
|
||||
command: 'list',
|
||||
projectDir: args.projectDir,
|
||||
connectionId: args.sourceConnectionId,
|
||||
json: args.json,
|
||||
};
|
||||
const listCode = await runLowLevelMapping(listArgs, args.json ? [...listArgv, '--json'] : listArgv, listIo, deps);
|
||||
if (listCode !== 0) {
|
||||
io.stderr.write(listIo.stderrText() || listIo.stdoutText() || `Failed to list mappings for ${args.sourceConnectionId}\n`);
|
||||
return listCode;
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
io.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
connectionId: args.sourceConnectionId,
|
||||
refresh: { ok: true, output: splitOutputLines(refreshIo.stdoutText()) },
|
||||
validation: { ok: true, output: splitOutputLines(validationIo.stdoutText()) },
|
||||
mappings: parseMappingListJson(listIo.stdoutText()),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
io.stdout.write(`Mapping: ${args.sourceConnectionId}\n`);
|
||||
io.stdout.write(refreshIo.stdoutText());
|
||||
io.stdout.write(validationIo.stdoutText());
|
||||
io.stdout.write('\nMappings:\n');
|
||||
io.stdout.write(listIo.stdoutText().trim() ? listIo.stdoutText() : 'No mappings found.\n');
|
||||
io.stdout.write('\nNext:\n');
|
||||
io.stdout.write(` klo ingest ${args.sourceConnectionId}\n`);
|
||||
io.stdout.write(` klo dev mapping list ${args.sourceConnectionId}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runKloConnection(
|
||||
args: KloConnectionArgs,
|
||||
io: KloConnectionIo = process,
|
||||
deps: KloConnectionDeps = {},
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (args.command === 'map') {
|
||||
return await runPublicConnectionMap(args, io, deps);
|
||||
}
|
||||
|
||||
const project = await loadKloProject({ projectDir: args.projectDir });
|
||||
if (args.command === 'list') {
|
||||
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (entries.length === 0) {
|
||||
io.stdout.write('No connections configured. Run `klo connection add <id> --driver <driver>` to add one.\n');
|
||||
return 0;
|
||||
}
|
||||
const idWidth = Math.max('ID'.length, ...entries.map(([id]) => id.length));
|
||||
const driverWidth = Math.max(
|
||||
'DRIVER'.length,
|
||||
...entries.map(([, c]) => (c.driver ?? 'unknown').length),
|
||||
);
|
||||
io.stdout.write(`${'ID'.padEnd(idWidth)} ${'DRIVER'.padEnd(driverWidth)}\n`);
|
||||
for (const [id, connection] of entries) {
|
||||
io.stdout.write(`${id.padEnd(idWidth)} ${(connection.driver ?? 'unknown').padEnd(driverWidth)}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'add') {
|
||||
assertSafeConnectionId(args.connectionId);
|
||||
const hasLiteralCredentialUrl = !!args.url && !isCredentialReference(args.url);
|
||||
if (hasLiteralCredentialUrl && !args.allowLiteralCredentials) {
|
||||
throw new Error('Literal credential URLs require --allow-literal-credentials');
|
||||
}
|
||||
if (hasLiteralCredentialUrl) {
|
||||
io.stderr.write(`${literalCredentialWarning(args.connectionId)}\n`);
|
||||
}
|
||||
if (project.config.connections[args.connectionId] && !args.force) {
|
||||
throw new Error(`Connection "${args.connectionId}" already exists; pass --force to replace it`);
|
||||
}
|
||||
const connectionConfig =
|
||||
args.driver === 'notion' && args.notion
|
||||
? {
|
||||
driver: 'notion',
|
||||
auth_token_ref: args.notion.authTokenRef,
|
||||
crawl_mode: args.notion.crawlMode,
|
||||
root_page_ids: args.notion.rootPageIds,
|
||||
root_database_ids: args.notion.rootDatabaseIds,
|
||||
root_data_source_ids: args.notion.rootDataSourceIds,
|
||||
...(args.notion.maxPagesPerRun !== undefined ? { max_pages_per_run: args.notion.maxPagesPerRun } : {}),
|
||||
...(args.notion.maxKnowledgeCreatesPerRun !== undefined
|
||||
? { max_knowledge_creates_per_run: args.notion.maxKnowledgeCreatesPerRun }
|
||||
: {}),
|
||||
...(args.notion.maxKnowledgeUpdatesPerRun !== undefined
|
||||
? { max_knowledge_updates_per_run: args.notion.maxKnowledgeUpdatesPerRun }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
driver: args.driver,
|
||||
...(args.url ? { url: args.url } : {}),
|
||||
...(args.schemas.length > 0 ? { schemas: args.schemas } : {}),
|
||||
readonly: args.readonly,
|
||||
};
|
||||
const nextConfig = {
|
||||
...project.config,
|
||||
connections: {
|
||||
...project.config.connections,
|
||||
[args.connectionId]: connectionConfig,
|
||||
},
|
||||
};
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(nextConfig),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
`Update KLO connection: ${args.connectionId}`,
|
||||
);
|
||||
io.stdout.write(`Connection: ${args.connectionId}\n`);
|
||||
io.stdout.write(`Driver: ${args.driver}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (args.command === 'remove') {
|
||||
if (!project.config.connections[args.connectionId]) {
|
||||
throw new Error(`Connection "${args.connectionId}" is not configured in klo.yaml`);
|
||||
}
|
||||
|
||||
if (!args.force) {
|
||||
if (!isInteractiveConnectionIo(args, io)) {
|
||||
throw new Error(
|
||||
`connection remove ${args.connectionId} requires --force when input is disabled or not interactive`,
|
||||
);
|
||||
}
|
||||
|
||||
const prompts = deps.prompts ?? createClackConnectionPromptAdapter();
|
||||
const confirmed = await prompts.confirm({
|
||||
message: `Remove connection "${args.connectionId}" from klo.yaml? Ingested artifacts will remain in .klo/.`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!confirmed) {
|
||||
prompts.cancel('Connection removal cancelled.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const { [args.connectionId]: _removedConnection, ...connections } = project.config.connections;
|
||||
const nextConfig = {
|
||||
...project.config,
|
||||
connections,
|
||||
};
|
||||
await project.fileStore.writeFile(
|
||||
'klo.yaml',
|
||||
serializeKloProjectConfig(nextConfig),
|
||||
'klo',
|
||||
'klo@example.com',
|
||||
`Remove KLO connection: ${args.connectionId}`,
|
||||
);
|
||||
io.stdout.write('Connection removed from klo.yaml.\n');
|
||||
io.stdout.write('Ingested artifacts from this connection remain in .klo/. Run klo dev artifacts to inspect.\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await testNativeConnection(
|
||||
project,
|
||||
args.connectionId,
|
||||
deps.createScanConnector ?? createKloCliScanConnector,
|
||||
);
|
||||
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
|
||||
io.stdout.write(`Driver: ${result.driver}\n`);
|
||||
io.stdout.write(`Tables: ${result.tableCount}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
303
packages/cli/src/context-build-view.test.ts
Normal file
303
packages/cli/src/context-build-view.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { buildDefaultKloProjectConfig, type KloProjectConfig } from '@klo/context/project';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { KloPublicIngestProject, KloPublicIngestTargetResult } from './public-ingest.js';
|
||||
import {
|
||||
extractProgressMessage,
|
||||
initViewState,
|
||||
parseIngestSummary,
|
||||
parseScanSummary,
|
||||
renderContextBuildView,
|
||||
runContextBuild,
|
||||
} from './context-build-view.js';
|
||||
|
||||
function makeIo(options: { isTTY?: boolean } = {}) {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
isTTY: options.isTTY,
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
};
|
||||
}
|
||||
|
||||
function projectWithConnections(connections: KloProjectConfig['connections']): KloPublicIngestProject {
|
||||
return {
|
||||
projectDir: '/tmp/project',
|
||||
config: {
|
||||
...buildDefaultKloProjectConfig('warehouse'),
|
||||
connections,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(connectionId: string, driver: string, operation: 'scan' | 'source-ingest'): KloPublicIngestTargetResult {
|
||||
return {
|
||||
connectionId,
|
||||
driver,
|
||||
steps: [
|
||||
{ operation: 'scan', status: operation === 'scan' ? 'done' : 'skipped' },
|
||||
{ operation: 'source-ingest', status: operation === 'source-ingest' ? 'done' : 'skipped' },
|
||||
{ operation: 'enrich', status: 'skipped' },
|
||||
{ operation: 'memory-update', status: operation === 'source-ingest' ? 'done' : 'skipped' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function failedResult(connectionId: string, driver: string, operation: 'scan' | 'source-ingest'): KloPublicIngestTargetResult {
|
||||
return {
|
||||
connectionId,
|
||||
driver,
|
||||
steps: [
|
||||
{ operation: 'scan', status: operation === 'scan' ? 'failed' : 'skipped', detail: `${connectionId} failed at scan.` },
|
||||
{ operation: 'source-ingest', status: operation === 'source-ingest' ? 'failed' : 'skipped' },
|
||||
{ operation: 'enrich', status: 'skipped' },
|
||||
{ operation: 'memory-update', status: 'not-run' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('extractProgressMessage', () => {
|
||||
it('extracts percentage and message from scan progress', () => {
|
||||
expect(extractProgressMessage('\r[45%] Scanning tables...[K')).toBe('[45%] Scanning tables...');
|
||||
});
|
||||
|
||||
it('extracts from permanent progress lines', () => {
|
||||
expect(extractProgressMessage('[100%] Done\n')).toBe('[100%] Done');
|
||||
});
|
||||
|
||||
it('returns null for non-progress output', () => {
|
||||
expect(extractProgressMessage('KLO scan completed\n')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseScanSummary', () => {
|
||||
it('extracts table count from scan output', () => {
|
||||
expect(parseScanSummary('Semantic layer comparison found 5 changes across 42 tables')).toBe('42 tables');
|
||||
});
|
||||
|
||||
it('handles singular form', () => {
|
||||
expect(parseScanSummary('found 1 change across 1 table')).toBe('1 tables');
|
||||
});
|
||||
|
||||
it('returns null when no match', () => {
|
||||
expect(parseScanSummary('No changes detected')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIngestSummary', () => {
|
||||
it('extracts work units and saved memory', () => {
|
||||
expect(parseIngestSummary('Work units: 5\nSaved memory: 3 wiki, 2 SL')).toBe('5 work units · 3 wiki, 2 SL');
|
||||
});
|
||||
|
||||
it('extracts work units alone when no saved memory', () => {
|
||||
expect(parseIngestSummary('Work units: 5\nStatus: done')).toBe('5 work units');
|
||||
});
|
||||
|
||||
it('extracts saved memory alone when no work units', () => {
|
||||
expect(parseIngestSummary('Saved memory: 3 wiki, 2 SL')).toBe('3 wiki, 2 SL');
|
||||
});
|
||||
|
||||
it('returns null when no match', () => {
|
||||
expect(parseIngestSummary('Status: done')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initViewState', () => {
|
||||
it('partitions targets into primary and context sources', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'warehouse', driver: 'postgres', operation: 'scan', debugCommand: '', steps: ['scan'] },
|
||||
{ connectionId: 'dbt-main', driver: 'dbt', operation: 'source-ingest', adapter: 'dbt', debugCommand: '', steps: ['source-ingest', 'memory-update'] },
|
||||
]);
|
||||
|
||||
expect(state.primarySources).toHaveLength(1);
|
||||
expect(state.primarySources[0].target.connectionId).toBe('warehouse');
|
||||
expect(state.contextSources).toHaveLength(1);
|
||||
expect(state.contextSources[0].target.connectionId).toBe('dbt-main');
|
||||
expect(state.frame).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderContextBuildView', () => {
|
||||
it('renders all-queued state', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'warehouse', driver: 'postgres', operation: 'scan', debugCommand: '', steps: ['scan'] },
|
||||
{ connectionId: 'dbt-main', driver: 'dbt', operation: 'source-ingest', adapter: 'dbt', debugCommand: '', steps: ['source-ingest', 'memory-update'] },
|
||||
]);
|
||||
|
||||
const output = renderContextBuildView(state, { styled: false });
|
||||
expect(output).toContain('Building KLO context');
|
||||
expect(output).toContain('Primary sources:');
|
||||
expect(output).toContain('warehouse');
|
||||
expect(output).toContain('queued');
|
||||
expect(output).toContain('Context sources:');
|
||||
expect(output).toContain('dbt-main');
|
||||
});
|
||||
|
||||
it('renders completed state with summary', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'warehouse', driver: 'postgres', operation: 'scan', debugCommand: '', steps: ['scan'] },
|
||||
]);
|
||||
state.primarySources[0].status = 'done';
|
||||
state.primarySources[0].elapsedMs = 72000;
|
||||
state.primarySources[0].summaryText = '42 tables';
|
||||
|
||||
const output = renderContextBuildView(state, { styled: false });
|
||||
expect(output).toContain('42 tables');
|
||||
expect(output).toContain('1m12s');
|
||||
});
|
||||
|
||||
it('renders failed state', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'warehouse', driver: 'postgres', operation: 'scan', debugCommand: '', steps: ['scan'] },
|
||||
]);
|
||||
state.primarySources[0].status = 'failed';
|
||||
|
||||
const output = renderContextBuildView(state, { styled: false });
|
||||
expect(output).toContain('✗');
|
||||
expect(output).toContain('failed');
|
||||
});
|
||||
|
||||
it('omits empty groups', () => {
|
||||
const state = initViewState([
|
||||
{ connectionId: 'dbt-main', driver: 'dbt', operation: 'source-ingest', adapter: 'dbt', debugCommand: '', steps: ['source-ingest', 'memory-update'] },
|
||||
]);
|
||||
|
||||
const output = renderContextBuildView(state, { styled: false });
|
||||
expect(output).not.toContain('Primary sources:');
|
||||
expect(output).toContain('Context sources:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runContextBuild', () => {
|
||||
it('executes scan targets before source-ingest targets', async () => {
|
||||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
dbt_main: { driver: 'dbt' },
|
||||
warehouse: { driver: 'postgres' },
|
||||
});
|
||||
const callOrder: string[] = [];
|
||||
const executeTarget = vi.fn(async (target) => {
|
||||
callOrder.push(target.connectionId);
|
||||
return successResult(target.connectionId, target.driver, target.operation);
|
||||
});
|
||||
|
||||
const result = await runContextBuild(
|
||||
project,
|
||||
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{ executeTarget, now: () => 1000 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ exitCode: 0, detached: false });
|
||||
expect(callOrder).toEqual(['warehouse', 'dbt_main']);
|
||||
});
|
||||
|
||||
it('returns exit code 1 when any target fails', async () => {
|
||||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
});
|
||||
const executeTarget = vi.fn(async (target) => failedResult(target.connectionId, target.driver, target.operation));
|
||||
|
||||
const result = await runContextBuild(
|
||||
project,
|
||||
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{ executeTarget, now: () => 1000 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ exitCode: 1, detached: false });
|
||||
});
|
||||
|
||||
it('renders final view for non-TTY output', async () => {
|
||||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
dbt_main: { driver: 'dbt' },
|
||||
});
|
||||
const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
|
||||
|
||||
await runContextBuild(
|
||||
project,
|
||||
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{ executeTarget, now: () => 1000 },
|
||||
);
|
||||
|
||||
const output = io.stdout();
|
||||
expect(output).toContain('Building KLO context');
|
||||
expect(output).toContain('Primary sources:');
|
||||
expect(output).toContain('warehouse');
|
||||
expect(output).toContain('Context sources:');
|
||||
expect(output).toContain('dbt_main');
|
||||
});
|
||||
|
||||
it('passes scan mode and detect relationships through to target execution', async () => {
|
||||
const io = makeIo();
|
||||
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||
const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
|
||||
|
||||
await runContextBuild(
|
||||
project,
|
||||
{ projectDir: '/tmp/project', inputMode: 'disabled', scanMode: 'enriched', detectRelationships: true },
|
||||
io.io,
|
||||
{ executeTarget, now: () => 1000 },
|
||||
);
|
||||
|
||||
expect(executeTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'warehouse', operation: 'scan' }),
|
||||
expect.objectContaining({ scanMode: 'enriched', detectRelationships: true }),
|
||||
expect.anything(),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('exits immediately with paused message when d is pressed', async () => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
dbt_main: { driver: 'dbt' },
|
||||
});
|
||||
let triggerDetach: (() => void) | null = null;
|
||||
const executeTarget = vi.fn(async (target) => {
|
||||
if (target.connectionId === 'warehouse') triggerDetach?.();
|
||||
return successResult(target.connectionId, target.driver, target.operation);
|
||||
});
|
||||
|
||||
await expect(
|
||||
runContextBuild(
|
||||
project,
|
||||
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||
io.io,
|
||||
{
|
||||
executeTarget,
|
||||
now: () => 1000,
|
||||
setupKeystroke: (onDetach) => {
|
||||
triggerDetach = onDetach;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('process.exit');
|
||||
|
||||
expect(mockExit).toHaveBeenCalledWith(0);
|
||||
expect(io.stdout()).toContain('Context build continuing in the background.');
|
||||
expect(io.stdout()).toContain('Resume: klo setup --project-dir /tmp/project');
|
||||
mockExit.mockRestore();
|
||||
});
|
||||
});
|
||||
414
packages/cli/src/context-build-view.ts
Normal file
414
packages/cli/src/context-build-view.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, openSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { KloCliIo } from './index.js';
|
||||
import type {
|
||||
KloPublicIngestArgs,
|
||||
KloPublicIngestPlanTarget,
|
||||
KloPublicIngestProject,
|
||||
KloPublicIngestTargetResult,
|
||||
} from './public-ingest.js';
|
||||
import { buildPublicIngestPlan, executePublicIngestTarget } from './public-ingest.js';
|
||||
import { formatDuration } from './demo-metrics.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
profileMark('module:context-build-view');
|
||||
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
|
||||
export interface ContextBuildTargetState {
|
||||
target: KloPublicIngestPlanTarget;
|
||||
status: 'queued' | 'running' | 'done' | 'failed';
|
||||
detailLine: string | null;
|
||||
summaryText: string | null;
|
||||
startedAt: number | null;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ContextBuildViewState {
|
||||
primarySources: ContextBuildTargetState[];
|
||||
contextSources: ContextBuildTargetState[];
|
||||
frame: number;
|
||||
}
|
||||
|
||||
export interface ContextBuildArgs {
|
||||
projectDir: string;
|
||||
inputMode: 'auto' | 'disabled';
|
||||
scanMode?: 'structural' | 'enriched';
|
||||
detectRelationships?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextBuildResult {
|
||||
exitCode: number;
|
||||
detached: boolean;
|
||||
}
|
||||
|
||||
export interface ContextBuildDeps {
|
||||
executeTarget?: typeof executePublicIngestTarget;
|
||||
now?: () => number;
|
||||
setupKeystroke?: (onDetach: () => void, onCtrlC: () => void) => (() => void) | null;
|
||||
onDetach?: () => void;
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
function green(text: string): string {
|
||||
return `${ESC}[32m${text}${ESC}[39m`;
|
||||
}
|
||||
|
||||
function red(text: string): string {
|
||||
return `${ESC}[31m${text}${ESC}[39m`;
|
||||
}
|
||||
|
||||
function cyan(text: string): string {
|
||||
return `${ESC}[36m${text}${ESC}[39m`;
|
||||
}
|
||||
|
||||
function dim(text: string): string {
|
||||
return `${ESC}[2m${text}${ESC}[22m`;
|
||||
}
|
||||
|
||||
function statusIcon(status: ContextBuildTargetState['status'], frame: number, styled: boolean): string {
|
||||
if (!styled) {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return '✓';
|
||||
case 'failed':
|
||||
return '✗';
|
||||
case 'running':
|
||||
return SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? '⠋';
|
||||
default:
|
||||
return '·';
|
||||
}
|
||||
}
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return green('✓');
|
||||
case 'failed':
|
||||
return red('✗');
|
||||
case 'running':
|
||||
return cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? '⠋');
|
||||
default:
|
||||
return dim('·');
|
||||
}
|
||||
}
|
||||
|
||||
function targetDetail(target: ContextBuildTargetState, styled: boolean): string {
|
||||
if (target.status === 'done') {
|
||||
const parts: string[] = [];
|
||||
if (target.summaryText) parts.push(target.summaryText);
|
||||
parts.push(formatDuration(target.elapsedMs));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
if (target.status === 'failed') {
|
||||
return styled ? red('failed') : 'failed';
|
||||
}
|
||||
if (target.status === 'running') {
|
||||
return target.detailLine ?? (target.target.operation === 'scan' ? 'scanning...' : 'ingesting...');
|
||||
}
|
||||
return styled ? dim('queued') : 'queued';
|
||||
}
|
||||
|
||||
function columnWidth(state: ContextBuildViewState): number {
|
||||
const all = [...state.primarySources, ...state.contextSources];
|
||||
return Math.max(12, ...all.map((t) => t.target.connectionId.length)) + 2;
|
||||
}
|
||||
|
||||
function renderTargetLine(target: ContextBuildTargetState, frame: number, styled: boolean, width: number): string {
|
||||
return ` ${statusIcon(target.status, frame, styled)} ${target.target.connectionId.padEnd(width)} ${targetDetail(target, styled)}`;
|
||||
}
|
||||
|
||||
function renderTargetGroup(
|
||||
label: string,
|
||||
targets: ContextBuildTargetState[],
|
||||
frame: number,
|
||||
styled: boolean,
|
||||
width: number,
|
||||
): string[] {
|
||||
if (targets.length === 0) return [];
|
||||
return ['', ` ${label}:`, ...targets.map((t) => renderTargetLine(t, frame, styled, width))];
|
||||
}
|
||||
|
||||
function resumeCommand(projectDir?: string): string {
|
||||
return projectDir ? `klo setup --project-dir ${projectDir}` : 'klo setup';
|
||||
}
|
||||
|
||||
export function renderContextBuildView(
|
||||
state: ContextBuildViewState,
|
||||
options: { styled?: boolean; showHint?: boolean; projectDir?: string } = {},
|
||||
): string {
|
||||
const styled = options.styled ?? true;
|
||||
const width = columnWidth(state);
|
||||
const lines: string[] = [
|
||||
'',
|
||||
'Building KLO context',
|
||||
'─────────────────────',
|
||||
...renderTargetGroup('Primary sources', state.primarySources, state.frame, styled, width),
|
||||
...renderTargetGroup('Context sources', state.contextSources, state.frame, styled, width),
|
||||
'',
|
||||
];
|
||||
const hasActive = [...state.primarySources, ...state.contextSources].some(
|
||||
(t) => t.status === 'running' || t.status === 'queued',
|
||||
);
|
||||
if (options.showHint && hasActive) {
|
||||
const hint = ` d to detach · ${resumeCommand(options.projectDir)} to resume`;
|
||||
lines.push(styled ? dim(hint) : hint);
|
||||
lines.push('');
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
// --- IO Capture ---
|
||||
|
||||
const ESC_K_RE = new RegExp(`${ESC.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[K`, 'g');
|
||||
|
||||
export function extractProgressMessage(chunk: string): string | null {
|
||||
const cleaned = chunk.replace(/^\r/, '').replace(ESC_K_RE, '').replace(/\n$/, '').trim();
|
||||
const match = cleaned.match(/^\[(\d+)%\]\s*(.+)$/);
|
||||
return match ? `[${match[1]}%] ${match[2]}` : null;
|
||||
}
|
||||
|
||||
export function parseScanSummary(output: string): string | null {
|
||||
const match = output.match(/(\d+) changes? across (\d+) tables?/);
|
||||
return match ? `${match[2]} tables` : null;
|
||||
}
|
||||
|
||||
export function parseIngestSummary(output: string): string | null {
|
||||
const parts: string[] = [];
|
||||
const workUnits = output.match(/Work units: (\d+)/);
|
||||
if (workUnits) parts.push(`${workUnits[1]} work units`);
|
||||
const savedMemory = output.match(/Saved memory: (.+)/);
|
||||
if (savedMemory) parts.push(savedMemory[1]);
|
||||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
interface CapturedIo {
|
||||
io: KloCliIo;
|
||||
captured(): string;
|
||||
}
|
||||
|
||||
function createCaptureIo(onProgress: (message: string) => void, isTTY: boolean): CapturedIo {
|
||||
let buffer = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: {
|
||||
isTTY,
|
||||
write(chunk: string) {
|
||||
buffer += chunk;
|
||||
const progress = extractProgressMessage(chunk);
|
||||
if (progress) onProgress(progress);
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write(chunk: string) {
|
||||
buffer += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
captured: () => buffer,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Repaint ---
|
||||
|
||||
function createRepainter(io: KloCliIo) {
|
||||
let lastLineCount = 0;
|
||||
|
||||
return {
|
||||
paint(content: string) {
|
||||
if (lastLineCount > 0) {
|
||||
io.stdout.write(`${ESC}[${lastLineCount}A\r`);
|
||||
}
|
||||
io.stdout.write(content);
|
||||
io.stdout.write(`${ESC}[J`);
|
||||
lastLineCount = (content.match(/\n/g) ?? []).length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Background build ---
|
||||
|
||||
function resolveKloEntryScript(): string | null {
|
||||
const argv1 = process.argv[1];
|
||||
if (argv1 && (argv1.endsWith('.js') || argv1.endsWith('.ts') || argv1.endsWith('.mjs'))) {
|
||||
return argv1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function spawnBackgroundBuild(projectDir: string): { logPath: string } | null {
|
||||
const entryScript = resolveKloEntryScript();
|
||||
if (!entryScript) return null;
|
||||
|
||||
const resolvedDir = resolve(projectDir);
|
||||
const logDir = join(resolvedDir, '.klo', 'setup');
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const logPath = join(logDir, 'context-build.log');
|
||||
const logFd = openSync(logPath, 'w');
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[entryScript, 'setup', 'context', 'build', '--project-dir', resolvedDir, '--no-input'],
|
||||
{ detached: true, stdio: ['ignore', logFd, logFd] },
|
||||
);
|
||||
child.unref();
|
||||
return { logPath };
|
||||
}
|
||||
|
||||
// --- Keystroke handling ---
|
||||
|
||||
function defaultSetupKeystroke(onDetach: () => void, onCtrlC: () => void): (() => void) | null {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
||||
return null;
|
||||
}
|
||||
stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
const onData = (data: Buffer) => {
|
||||
const char = data.toString();
|
||||
if (char === 'd' || char === 'D') onDetach();
|
||||
else if (char === '\x03') onCtrlC();
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
return () => {
|
||||
stdin.off('data', onData);
|
||||
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
|
||||
stdin.pause();
|
||||
};
|
||||
}
|
||||
|
||||
// --- Orchestration ---
|
||||
|
||||
function makeTargetState(target: KloPublicIngestPlanTarget): ContextBuildTargetState {
|
||||
return { target, status: 'queued', detailLine: null, summaryText: null, startedAt: null, elapsedMs: 0 };
|
||||
}
|
||||
|
||||
export function initViewState(targets: KloPublicIngestPlanTarget[]): ContextBuildViewState {
|
||||
return {
|
||||
primarySources: targets.filter((t) => t.operation === 'scan').map(makeTargetState),
|
||||
contextSources: targets.filter((t) => t.operation === 'source-ingest').map(makeTargetState),
|
||||
frame: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runContextBuild(
|
||||
project: KloPublicIngestProject,
|
||||
args: ContextBuildArgs,
|
||||
io: KloCliIo,
|
||||
deps: ContextBuildDeps = {},
|
||||
): Promise<ContextBuildResult> {
|
||||
const plan = buildPublicIngestPlan(project, { projectDir: args.projectDir, all: true });
|
||||
const state = initViewState(plan.targets);
|
||||
const isTTY = io.stdout.isTTY === true;
|
||||
const nowFn = deps.now ?? (() => Date.now());
|
||||
|
||||
const repainter = isTTY ? createRepainter(io) : null;
|
||||
const viewOpts = { styled: true, projectDir: args.projectDir };
|
||||
const paint = (hint: boolean) => repainter?.paint(renderContextBuildView(state, { ...viewOpts, showHint: hint }));
|
||||
paint(true);
|
||||
|
||||
let spinnerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
if (repainter) {
|
||||
spinnerInterval = setInterval(() => {
|
||||
state.frame++;
|
||||
for (const t of [...state.primarySources, ...state.contextSources]) {
|
||||
if (t.status === 'running' && t.startedAt !== null) {
|
||||
t.elapsedMs = nowFn() - t.startedAt;
|
||||
}
|
||||
}
|
||||
paint(true);
|
||||
}, 140);
|
||||
}
|
||||
|
||||
const orderedTargets = [...state.primarySources, ...state.contextSources];
|
||||
const execTarget = deps.executeTarget ?? executePublicIngestTarget;
|
||||
|
||||
let detached = false;
|
||||
let cleanupKeystroke: (() => void) | null = null;
|
||||
|
||||
if (isTTY || deps.setupKeystroke) {
|
||||
const cleanup = () => {
|
||||
if (spinnerInterval) clearInterval(spinnerInterval);
|
||||
cleanupKeystroke?.();
|
||||
};
|
||||
cleanupKeystroke = (deps.setupKeystroke ?? defaultSetupKeystroke)(
|
||||
() => {
|
||||
cleanup();
|
||||
deps.onDetach?.();
|
||||
const bg = spawnBackgroundBuild(args.projectDir);
|
||||
io.stdout.write('\n\nContext build continuing in the background.\n');
|
||||
if (bg) io.stdout.write(`Log: ${bg.logPath}\n`);
|
||||
io.stdout.write(`Status: klo setup context status --project-dir ${resolve(args.projectDir)}\n`);
|
||||
io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`);
|
||||
process.exit(0);
|
||||
},
|
||||
() => {
|
||||
cleanup();
|
||||
io.stdout.write('\n\nContext build stopped. Nothing is running in the background.\n');
|
||||
io.stdout.write(`Resume: ${resumeCommand(args.projectDir)}\n`);
|
||||
process.exit(130);
|
||||
},
|
||||
);
|
||||
}
|
||||
const runArgs: Extract<KloPublicIngestArgs, { command: 'run' }> = {
|
||||
command: 'run',
|
||||
projectDir: args.projectDir,
|
||||
all: true,
|
||||
json: false,
|
||||
inputMode: args.inputMode,
|
||||
scanMode: args.scanMode,
|
||||
detectRelationships: args.detectRelationships,
|
||||
};
|
||||
|
||||
let hasFailure = false;
|
||||
|
||||
try {
|
||||
for (const targetState of orderedTargets) {
|
||||
if (detached) break;
|
||||
|
||||
targetState.status = 'running';
|
||||
targetState.startedAt = nowFn();
|
||||
paint(true);
|
||||
|
||||
const capture = createCaptureIo(
|
||||
(message) => {
|
||||
targetState.detailLine = message;
|
||||
paint(true);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const result = await execTarget(targetState.target, runArgs, capture.io, {});
|
||||
|
||||
targetState.elapsedMs = nowFn() - (targetState.startedAt ?? nowFn());
|
||||
const failed = result.steps.some((s) => s.status === 'failed');
|
||||
targetState.status = failed ? 'failed' : 'done';
|
||||
targetState.detailLine = null;
|
||||
if (!failed) {
|
||||
targetState.summaryText =
|
||||
targetState.target.operation === 'scan'
|
||||
? parseScanSummary(capture.captured())
|
||||
: parseIngestSummary(capture.captured());
|
||||
}
|
||||
if (failed) hasFailure = true;
|
||||
|
||||
paint(true);
|
||||
}
|
||||
} finally {
|
||||
if (spinnerInterval) clearInterval(spinnerInterval);
|
||||
cleanupKeystroke?.();
|
||||
}
|
||||
|
||||
if (detached) {
|
||||
return { exitCode: 0, detached: true };
|
||||
}
|
||||
|
||||
if (!repainter) {
|
||||
io.stdout.write(renderContextBuildView(state, { styled: false }));
|
||||
} else {
|
||||
paint(false);
|
||||
}
|
||||
|
||||
return { exitCode: hasFailure ? 1 : 0, detached: false };
|
||||
}
|
||||
272
packages/cli/src/demo-assets.test.ts
Normal file
272
packages/cli/src/demo-assets.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { access, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEMO_ADAPTER,
|
||||
DEMO_CONNECTION_ID,
|
||||
DEMO_FULL_JOB_ID,
|
||||
DEMO_REPLAY_FILE,
|
||||
defaultDemoProjectDir,
|
||||
ensureDemoProject,
|
||||
inspectDemoProjectState,
|
||||
loadPackagedDemoReplay,
|
||||
loadProjectDemoReplay,
|
||||
resetDemoProject,
|
||||
} from './demo-assets.js';
|
||||
import { writeDemoReplay } from './demo-replay-store.js';
|
||||
|
||||
const packagedDemoSource = 'packaged-orbit-demo';
|
||||
|
||||
function packagedDemoAssetPath(relativePath: string): string {
|
||||
return fileURLToPath(new URL(`../assets/demo/orbit/${relativePath}`, import.meta.url));
|
||||
}
|
||||
|
||||
async function readPackagedJson<T>(relativePath: string): Promise<T> {
|
||||
return JSON.parse(await readFile(packagedDemoAssetPath(relativePath), 'utf-8')) as T;
|
||||
}
|
||||
|
||||
describe('demo assets', () => {
|
||||
const projectDir = join(tmpdir(), `klo-demo-assets-${process.pid}`);
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves the default demo root under the OS temp directory', () => {
|
||||
const dir = defaultDemoProjectDir();
|
||||
expect(dir.startsWith(join(tmpdir(), 'klo-demo-'))).toBe(true);
|
||||
expect(dir).toMatch(/klo-demo-[a-f0-9]{8}$/);
|
||||
});
|
||||
|
||||
it('exports the packaged Orbit demo identity', () => {
|
||||
expect(DEMO_CONNECTION_ID).toBe('orbit_demo');
|
||||
expect(DEMO_ADAPTER).toBe('live-database');
|
||||
expect(DEMO_REPLAY_FILE).toBe('replay.memory-flow.v1.json');
|
||||
expect(DEMO_FULL_JOB_ID).toBe('demo-full-ingest');
|
||||
});
|
||||
|
||||
it('ships the seeded demo bundle required by the May 6 PRD', async () => {
|
||||
const manifest = await readPackagedJson<{
|
||||
demoAssetSchemaVersion: number;
|
||||
mode: string;
|
||||
source: string;
|
||||
sources: {
|
||||
warehouse: { tables: number; rowCounts: Record<string, number> };
|
||||
dbt: { models: number; sourceTables: number };
|
||||
bi: { explores: number; dashboards: number };
|
||||
notion: { pages: number };
|
||||
};
|
||||
name: string;
|
||||
displayName: string;
|
||||
generated: {
|
||||
semanticLayer: { path: string; sourceCount: number };
|
||||
knowledge: { pageCount: number };
|
||||
links: { linkCount: number };
|
||||
};
|
||||
}>('manifest.json');
|
||||
|
||||
expect(manifest).toMatchObject({
|
||||
demoAssetSchemaVersion: 2,
|
||||
name: 'orbit',
|
||||
displayName: 'Orbit Demo',
|
||||
mode: 'seeded',
|
||||
source: packagedDemoSource,
|
||||
});
|
||||
expect(manifest.sources.warehouse.tables).toBeGreaterThanOrEqual(5);
|
||||
expect(manifest.sources.warehouse.tables).toBeLessThanOrEqual(10);
|
||||
expect(Object.keys(manifest.sources.warehouse.rowCounts).sort()).toEqual([
|
||||
'accounts',
|
||||
'arr_movements',
|
||||
'contracts',
|
||||
'invoices',
|
||||
'plans',
|
||||
'purchase_requests',
|
||||
'support_tickets',
|
||||
'users',
|
||||
]);
|
||||
expect(manifest.sources.dbt.models).toBeGreaterThanOrEqual(3);
|
||||
expect(manifest.sources.dbt.models).toBeLessThanOrEqual(6);
|
||||
expect(manifest.sources.bi.explores).toBeGreaterThanOrEqual(2);
|
||||
expect(manifest.sources.bi.dashboards).toBeGreaterThanOrEqual(2);
|
||||
expect(manifest.sources.notion.pages).toBeGreaterThanOrEqual(5);
|
||||
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(5);
|
||||
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(10);
|
||||
expect(manifest.generated.links.linkCount).toBeGreaterThanOrEqual(10);
|
||||
|
||||
const dbStat = await stat(packagedDemoAssetPath('demo.db'));
|
||||
expect(dbStat.size).toBeGreaterThan(0);
|
||||
expect(dbStat.size).toBeLessThan(10 * 1024 * 1024);
|
||||
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/warehouse/accounts.csv'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/dbt/schema.yml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/bi/revenue_exec.dashboard.lookml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('raw-sources/notion/revenue-reporting-policy.md'))).resolves.toBeUndefined();
|
||||
expect(manifest.generated.semanticLayer.path).toBe('semantic-layer/orbit_demo');
|
||||
|
||||
await expect(access(packagedDemoAssetPath('semantic-layer/orbit_demo/accounts.yaml'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('knowledge/global/arr-contract-first.md'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('links/provenance.json'))).resolves.toBeUndefined();
|
||||
await expect(access(packagedDemoAssetPath('reports/seeded-demo-report.json'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('initializes a flat demo project without writing literal credentials', async () => {
|
||||
const result = await ensureDemoProject({ projectDir, force: false });
|
||||
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'state.sqlite'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'reports'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'semantic-layer'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'knowledge'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'replays', 'replay.memory-flow.v1.json'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, 'raw-sources'))).resolves.toBeUndefined();
|
||||
await expect(access(join(projectDir, '_schema'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
||||
const config = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(config).toContain('backend: anthropic');
|
||||
expect(config).toContain('api_key: env:ANTHROPIC_API_KEY');
|
||||
expect(config).not.toContain('sk-ant-');
|
||||
});
|
||||
|
||||
it('rejects an existing demo project unless force is set', async () => {
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
await expect(ensureDemoProject({ projectDir, force: false })).rejects.toThrow('Demo project already exists');
|
||||
await expect(ensureDemoProject({ projectDir, force: true })).resolves.toMatchObject({ projectDir });
|
||||
});
|
||||
|
||||
it('loads packaged and copied demo replays', async () => {
|
||||
const packaged = await loadPackagedDemoReplay();
|
||||
expect(packaged.runId).toBe('demo-seeded-orbit');
|
||||
expect(packaged.connectionId).toBe('orbit_demo');
|
||||
expect(packaged.metadata?.mode).toBe('seeded');
|
||||
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
const copied = await loadProjectDemoReplay(projectDir);
|
||||
expect(copied).toEqual(packaged);
|
||||
});
|
||||
|
||||
it('loads the latest local replay before the packaged replay', async () => {
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
await writeDemoReplay(
|
||||
projectDir,
|
||||
{
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: null,
|
||||
sourceReportPath: 'raw-sources/orbit_demo/live-database/sync/scan-report.json',
|
||||
fallbackReason: null,
|
||||
},
|
||||
runId: 'demo-full-run',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'sync',
|
||||
reportPath: 'raw-sources/orbit_demo/live-database/sync/scan-report.json',
|
||||
errors: [],
|
||||
events: [{ type: 'report_created', runId: 'scan-run' }],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
},
|
||||
{ label: 'full' },
|
||||
);
|
||||
|
||||
await expect(loadProjectDemoReplay(projectDir)).resolves.toMatchObject({
|
||||
runId: 'demo-full-run',
|
||||
metadata: { mode: 'full', origin: 'captured' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing, ready, and corrupted demo project state', async () => {
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
|
||||
status: 'missing',
|
||||
projectDir,
|
||||
missing: ['klo.yaml', 'demo.db', 'state.sqlite', 'replays/replay.memory-flow.v1.json'],
|
||||
});
|
||||
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
|
||||
status: 'ready',
|
||||
projectDir,
|
||||
missing: [],
|
||||
});
|
||||
|
||||
await rm(join(projectDir, 'demo.db'), { force: true });
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toEqual({
|
||||
status: 'corrupt',
|
||||
projectDir,
|
||||
missing: ['demo.db'],
|
||||
});
|
||||
});
|
||||
|
||||
it('requires explicit force for demo reset and recreates packaged assets', async () => {
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
await rm(join(projectDir, 'demo.db'), { force: true });
|
||||
|
||||
await expect(resetDemoProject({ projectDir, force: false })).rejects.toThrow(
|
||||
`klo setup demo reset is destructive; pass --force to recreate ${projectDir}`,
|
||||
);
|
||||
|
||||
await expect(resetDemoProject({ projectDir, force: true })).resolves.toMatchObject({ projectDir });
|
||||
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toMatchObject({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('preserves a user-edited klo.yaml across reset --force', async () => {
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
const customConfig = [
|
||||
'project: klo-demo-orbit',
|
||||
'connections:',
|
||||
` ${DEMO_CONNECTION_ID}:`,
|
||||
' driver: sqlite',
|
||||
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
|
||||
' readonly: true',
|
||||
'storage:',
|
||||
' state: sqlite',
|
||||
' search: sqlite-fts5',
|
||||
' git:',
|
||||
' auto_commit: true',
|
||||
' author: klo <klo@example.com>',
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: vertex',
|
||||
' vertex:',
|
||||
' project: example-gcp-project',
|
||||
' location: us-east5',
|
||||
' models:',
|
||||
' default: claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
` - ${DEMO_ADAPTER}`,
|
||||
' embeddings:',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
' workUnits:',
|
||||
' stepBudget: 40',
|
||||
' maxConcurrency: 1',
|
||||
' failureMode: continue',
|
||||
'',
|
||||
].join('\n');
|
||||
await writeFile(join(projectDir, 'klo.yaml'), customConfig, 'utf-8');
|
||||
|
||||
await resetDemoProject({ projectDir, force: true });
|
||||
|
||||
const preserved = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(preserved).toBe(customConfig);
|
||||
expect(preserved).toContain('backend: vertex');
|
||||
expect(preserved).not.toContain('backend: anthropic');
|
||||
await expect(inspectDemoProjectState(projectDir)).resolves.toMatchObject({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('still writes the default klo.yaml on reset when none exists', async () => {
|
||||
await resetDemoProject({ projectDir, force: true });
|
||||
const config = await readFile(join(projectDir, 'klo.yaml'), 'utf-8');
|
||||
expect(config).toContain('backend: anthropic');
|
||||
});
|
||||
});
|
||||
281
packages/cli/src/demo-assets.ts
Normal file
281
packages/cli/src/demo-assets.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { MemoryFlowReplayInput } from '@klo/context/ingest/memory-flow';
|
||||
import { loadDemoReplayFile, loadLatestDemoReplay } from './demo-replay-store.js';
|
||||
|
||||
interface DemoProjectResult {
|
||||
projectDir: string;
|
||||
configPath: string;
|
||||
databasePath: string;
|
||||
replayPath: string;
|
||||
}
|
||||
|
||||
interface EnsureDemoProjectOptions {
|
||||
projectDir: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
type DemoProjectStateStatus = 'missing' | 'ready' | 'corrupt';
|
||||
|
||||
interface DemoProjectState {
|
||||
status: DemoProjectStateStatus;
|
||||
projectDir: string;
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
export const DEMO_CONNECTION_ID = 'orbit_demo';
|
||||
export const DEMO_ADAPTER = 'live-database';
|
||||
export const DEMO_REPLAY_FILE = 'replay.memory-flow.v1.json';
|
||||
export const DEMO_FULL_JOB_ID = 'demo-full-ingest';
|
||||
|
||||
const REQUIRED_BASE_PROJECT_PATHS = [
|
||||
'klo.yaml',
|
||||
'demo.db',
|
||||
'state.sqlite',
|
||||
join('replays', DEMO_REPLAY_FILE),
|
||||
] as const;
|
||||
|
||||
const REQUIRED_PACKAGED_BASE_ASSET_PATHS = ['demo.db', 'manifest.json', DEMO_REPLAY_FILE] as const;
|
||||
|
||||
const REQUIRED_SEEDED_ASSET_PATHS = [
|
||||
'demo.db',
|
||||
'manifest.json',
|
||||
DEMO_REPLAY_FILE,
|
||||
join('raw-sources', 'warehouse', 'accounts.csv'),
|
||||
join('raw-sources', 'dbt', 'schema.yml'),
|
||||
join('raw-sources', 'bi', 'revenue_exec.dashboard.lookml'),
|
||||
join('raw-sources', 'notion', 'revenue-reporting-policy.md'),
|
||||
join('semantic-layer', 'orbit_demo', 'accounts.yaml'),
|
||||
join('knowledge', 'global', 'arr-contract-first.md'),
|
||||
join('links', 'provenance.json'),
|
||||
join('reports', 'seeded-demo-report.json'),
|
||||
] as const;
|
||||
|
||||
function assetDir(): string {
|
||||
return fileURLToPath(new URL('../assets/demo/orbit/', import.meta.url));
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultDemoProjectDir(): string {
|
||||
const suffix = randomBytes(4).toString('hex');
|
||||
return join(tmpdir(), `klo-demo-${suffix}`);
|
||||
}
|
||||
|
||||
export async function inspectDemoProjectState(projectDir: string): Promise<DemoProjectState> {
|
||||
const root = resolve(projectDir);
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const relativePath of REQUIRED_BASE_PROJECT_PATHS) {
|
||||
if (!(await exists(join(root, relativePath)))) {
|
||||
missing.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === REQUIRED_BASE_PROJECT_PATHS.length) {
|
||||
return { status: 'missing', projectDir: root, missing };
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
return { status: 'corrupt', projectDir: root, missing };
|
||||
}
|
||||
|
||||
return { status: 'ready', projectDir: root, missing: [] };
|
||||
}
|
||||
|
||||
export async function resetDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
|
||||
const projectDir = resolve(options.projectDir);
|
||||
if (!options.force) {
|
||||
throw new Error(`klo setup demo reset is destructive; pass --force to recreate ${projectDir}`);
|
||||
}
|
||||
|
||||
const preservedConfig = await readExistingConfig(join(projectDir, 'klo.yaml'));
|
||||
const result = await ensureDemoProject({ projectDir, force: true });
|
||||
if (preservedConfig !== null) {
|
||||
await writeFile(result.configPath, preservedConfig, 'utf-8');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readExistingConfig(configPath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(configPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function demoConfig(databasePath: string): string {
|
||||
return [
|
||||
'project: klo-demo-orbit',
|
||||
'connections:',
|
||||
` ${DEMO_CONNECTION_ID}:`,
|
||||
' driver: sqlite',
|
||||
` path: ${JSON.stringify(databasePath)}`,
|
||||
' readonly: true',
|
||||
'storage:',
|
||||
' state: sqlite',
|
||||
' search: sqlite-fts5',
|
||||
' git:',
|
||||
' auto_commit: true',
|
||||
' author: klo <klo@example.com>',
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: anthropic',
|
||||
' anthropic:',
|
||||
' api_key: env:ANTHROPIC_API_KEY',
|
||||
' models:',
|
||||
' default: claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
` - ${DEMO_ADAPTER}`,
|
||||
' embeddings:',
|
||||
' backend: none',
|
||||
' dimensions: 8',
|
||||
' workUnits:',
|
||||
' stepBudget: 40',
|
||||
' maxConcurrency: 1',
|
||||
' failureMode: continue',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function copyPackagedReplay(projectDir: string): Promise<string> {
|
||||
const replayDir = join(projectDir, 'replays');
|
||||
await mkdir(replayDir, { recursive: true });
|
||||
const replayPath = join(replayDir, DEMO_REPLAY_FILE);
|
||||
await copyFile(join(assetDir(), DEMO_REPLAY_FILE), replayPath);
|
||||
return replayPath;
|
||||
}
|
||||
|
||||
async function assertPackagedBaseAssetsPresent(): Promise<void> {
|
||||
const missing: string[] = [];
|
||||
for (const relativePath of REQUIRED_PACKAGED_BASE_ASSET_PATHS) {
|
||||
if (!(await exists(join(assetDir(), relativePath)))) {
|
||||
missing.push(relativePath);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Packaged demo assets are incomplete: missing ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPackagedSeededAssetsPresent(): Promise<void> {
|
||||
const missing: string[] = [];
|
||||
for (const relativePath of REQUIRED_SEEDED_ASSET_PATHS) {
|
||||
if (!(await exists(join(assetDir(), relativePath)))) {
|
||||
missing.push(relativePath);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Packaged seeded demo assets are incomplete: missing ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
|
||||
const projectDir = resolve(options.projectDir);
|
||||
const configPath = join(projectDir, 'klo.yaml');
|
||||
if (!options.force && (await exists(configPath))) {
|
||||
throw new Error(`Demo project already exists at ${projectDir}; pass --force to recreate it`);
|
||||
}
|
||||
|
||||
await assertPackagedBaseAssetsPresent();
|
||||
|
||||
if (options.force) {
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
for (const relativeDir of ['reports', 'semantic-layer', 'knowledge', 'replays', 'raw-sources', 'links']) {
|
||||
await mkdir(join(projectDir, relativeDir), { recursive: true });
|
||||
}
|
||||
|
||||
const databasePath = join(projectDir, 'demo.db');
|
||||
await copyFile(join(assetDir(), 'demo.db'), databasePath);
|
||||
await writeFile(join(projectDir, 'state.sqlite'), '', { flag: 'a' });
|
||||
await copyFile(join(assetDir(), 'manifest.json'), join(projectDir, 'manifest.json'));
|
||||
const replayPath = await copyPackagedReplay(projectDir);
|
||||
await writeFile(configPath, demoConfig(databasePath), 'utf-8');
|
||||
|
||||
return { projectDir, configPath, databasePath, replayPath };
|
||||
}
|
||||
|
||||
async function copyDirIfExists(src: string, dest: string): Promise<void> {
|
||||
if (await exists(src)) {
|
||||
await cp(src, dest, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function copySeededAssetDirectories(projectDir: string): Promise<void> {
|
||||
const src = assetDir();
|
||||
const dest = resolve(projectDir);
|
||||
|
||||
await Promise.all([
|
||||
copyDirIfExists(join(src, 'semantic-layer'), join(dest, 'semantic-layer')),
|
||||
copyDirIfExists(join(src, 'knowledge'), join(dest, 'knowledge')),
|
||||
copyDirIfExists(join(src, 'raw-sources'), join(dest, 'raw-sources')),
|
||||
copyDirIfExists(join(src, 'links'), join(dest, 'links')),
|
||||
copyDirIfExists(join(src, 'reports'), join(dest, 'reports')),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function ensureSeededDemoProject(options: EnsureDemoProjectOptions): Promise<DemoProjectResult> {
|
||||
await assertPackagedSeededAssetsPresent();
|
||||
const projectDir = resolve(options.projectDir);
|
||||
const result = await ensureDemoProject(options).catch((error) => {
|
||||
if (!options.force && error instanceof Error && error.message.includes('Demo project already exists')) {
|
||||
return {
|
||||
projectDir,
|
||||
configPath: join(projectDir, 'klo.yaml'),
|
||||
databasePath: join(projectDir, 'demo.db'),
|
||||
replayPath: join(projectDir, 'replays', DEMO_REPLAY_FILE),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
await copySeededAssetDirectories(result.projectDir);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loadPackagedDemoReplay(): Promise<MemoryFlowReplayInput> {
|
||||
const replay = await loadDemoReplayFile(join(assetDir(), DEMO_REPLAY_FILE));
|
||||
return {
|
||||
...replay,
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: replay.metadata?.mode ?? 'seeded',
|
||||
origin: 'packaged',
|
||||
timing: replay.metadata?.timing ?? 'prebuilt',
|
||||
capturedAt: replay.metadata?.capturedAt ?? null,
|
||||
sourceReportId: replay.metadata?.sourceReportId ?? 'demo-seeded-report',
|
||||
sourceReportPath: replay.metadata?.sourceReportPath ?? `reports/seeded-demo-report.json`,
|
||||
fallbackReason: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadProjectDemoReplay(projectDir: string): Promise<MemoryFlowReplayInput> {
|
||||
const latest = await loadLatestDemoReplay(projectDir);
|
||||
if (latest) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const replayPath = join(resolve(projectDir), 'replays', DEMO_REPLAY_FILE);
|
||||
if (!(await exists(replayPath))) {
|
||||
await mkdir(dirname(replayPath), { recursive: true });
|
||||
await copyPackagedReplay(resolve(projectDir));
|
||||
}
|
||||
return loadPackagedDemoReplay();
|
||||
}
|
||||
201
packages/cli/src/demo-full.test.ts
Normal file
201
packages/cli/src/demo-full.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { IngestReportSnapshot, LocalIngestResult, RunLocalIngestOptions } from '@klo/context/ingest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
|
||||
import {
|
||||
assertFullDemoCredentials,
|
||||
buildFullDemoReplay,
|
||||
formatFullDemoSummary,
|
||||
fullDemoCredentialStatus,
|
||||
runDemoFull,
|
||||
} from './demo-full.js';
|
||||
|
||||
function fakeFullReport(): IngestReportSnapshot {
|
||||
return {
|
||||
id: 'report-full',
|
||||
runId: 'run-full',
|
||||
jobId: DEMO_FULL_JOB_ID,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
sourceKey: DEMO_ADAPTER,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
body: {
|
||||
syncId: 'sync-full',
|
||||
diffSummary: { added: 7, modified: 0, deleted: 0, unchanged: 0 },
|
||||
commitSha: null,
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'accounts',
|
||||
rawFiles: ['accounts.schema.json'],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{ target: 'wiki', type: 'created', key: 'knowledge/accounts.md', detail: 'account lifecycle context' },
|
||||
{ target: 'sl', type: 'created', key: 'orbit_demo.accounts', detail: 'accounts semantic source' },
|
||||
],
|
||||
touchedSlSources: [{ connectionId: 'orbit_demo', sourceName: 'orbit_demo.accounts' }],
|
||||
},
|
||||
],
|
||||
failedWorkUnits: [],
|
||||
reconciliationSkipped: false,
|
||||
conflictsResolved: [],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [],
|
||||
evictionInputs: [],
|
||||
unresolvedCards: [],
|
||||
supersededBy: null,
|
||||
overrideOf: null,
|
||||
provenanceRows: [
|
||||
{
|
||||
rawPath: 'accounts.schema.json',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/accounts.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
{
|
||||
rawPath: 'accounts.schema.json',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'orbit_demo.accounts',
|
||||
actionType: 'source_created',
|
||||
},
|
||||
],
|
||||
toolTranscripts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('full demo helpers', () => {
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-demo-full-'));
|
||||
projectDir = join(tempDir, 'demo');
|
||||
await ensureDemoProject({ projectDir, force: false });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails full mode with exact Anthropic env guidance when the key is missing', async () => {
|
||||
const project = await import('@klo/context/project').then((mod) => mod.loadKloProject({ projectDir }));
|
||||
|
||||
expect(() => assertFullDemoCredentials(project, {})).toThrow(
|
||||
'klo setup demo --mode full needs ANTHROPIC_API_KEY. Export ANTHROPIC_API_KEY and rerun `klo setup demo --mode full --no-input`, or run `klo setup demo --mode seeded --no-input` without credentials.',
|
||||
);
|
||||
});
|
||||
|
||||
it('respects an existing gateway provider project for full mode', async () => {
|
||||
await writeFile(
|
||||
join(projectDir, 'klo.yaml'),
|
||||
[
|
||||
'project: klo-demo-orbit',
|
||||
'connections:',
|
||||
' orbit_demo:',
|
||||
' driver: sqlite',
|
||||
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: gateway',
|
||||
' models:',
|
||||
' default: anthropic/claude-sonnet-4-6',
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - live-database',
|
||||
' embeddings:',
|
||||
' backend: none',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const project = await import('@klo/context/project').then((mod) => mod.loadKloProject({ projectDir }));
|
||||
|
||||
expect(() => assertFullDemoCredentials(project, {})).not.toThrow();
|
||||
expect(fullDemoCredentialStatus(project, {})).toEqual({ status: 'ready' });
|
||||
});
|
||||
|
||||
it('reports full-demo credential status without throwing', async () => {
|
||||
const project = await import('@klo/context/project').then((mod) => mod.loadKloProject({ projectDir }));
|
||||
|
||||
expect(fullDemoCredentialStatus(project, {})).toEqual({ status: 'missing-anthropic-key' });
|
||||
expect(fullDemoCredentialStatus(project, { ANTHROPIC_API_KEY: 'sk-ant-test' })).toEqual({ status: 'ready' }); // pragma: allowlist secret
|
||||
|
||||
await writeFile(
|
||||
join(projectDir, 'klo.yaml'),
|
||||
[
|
||||
'project: klo-demo-orbit',
|
||||
'connections:',
|
||||
' orbit_demo:',
|
||||
' driver: sqlite',
|
||||
` path: ${JSON.stringify(join(projectDir, 'demo.db'))}`,
|
||||
'ingest:',
|
||||
' adapters:',
|
||||
' - live-database',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const disabledProject = await import('@klo/context/project').then((mod) => mod.loadKloProject({ projectDir }));
|
||||
expect(fullDemoCredentialStatus(disabledProject, {})).toEqual({ status: 'unsupported-provider', provider: 'none' });
|
||||
});
|
||||
|
||||
it('runs scan first and then full ingest with the canonical demo connection', async () => {
|
||||
const report = fakeFullReport();
|
||||
const runLocalScan = vi.fn().mockResolvedValue({
|
||||
report: {
|
||||
runId: 'scan-run',
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
driver: 'sqlite',
|
||||
mode: 'structural',
|
||||
syncId: 'sync-scan',
|
||||
diffSummary: { tablesAdded: 7, tablesModified: 0, tablesDeleted: 0, tablesUnchanged: 0 },
|
||||
artifactPaths: { rawSourcesDir: 'raw-sources/orbit_demo/live-database/sync-scan', manifestShards: [], reportPath: 'scan-report.json' },
|
||||
},
|
||||
});
|
||||
const runLocalIngest = vi.fn(async (options: RunLocalIngestOptions): Promise<LocalIngestResult> => {
|
||||
expect(options.adapter).toBe(DEMO_ADAPTER);
|
||||
expect(options.connectionId).toBe(DEMO_CONNECTION_ID);
|
||||
expect(options.jobId).toBe(DEMO_FULL_JOB_ID);
|
||||
expect(options.memoryFlow?.snapshot()).toMatchObject({ runId: DEMO_FULL_JOB_ID, status: 'running' });
|
||||
options.memoryFlow?.emit({ type: 'source_acquired', adapter: DEMO_ADAPTER, trigger: 'demo_full', fileCount: 7 });
|
||||
return { result: { ok: true } as never, report };
|
||||
});
|
||||
const snapshots: unknown[] = [];
|
||||
|
||||
const result = await runDemoFull({
|
||||
projectDir,
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
|
||||
runLocalScan,
|
||||
runLocalIngest,
|
||||
onMemoryFlowChange: (snapshot) => snapshots.push(snapshot),
|
||||
});
|
||||
|
||||
expect(runLocalScan).toHaveBeenCalledTimes(1);
|
||||
expect(runLocalIngest).toHaveBeenCalledTimes(1);
|
||||
expect(result.report).toBe(report);
|
||||
expect(result.replay.runId).toBe('run-full');
|
||||
expect(snapshots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds replay and plain summary from the full report', () => {
|
||||
const report = fakeFullReport();
|
||||
const replay = buildFullDemoReplay(report);
|
||||
const summary = formatFullDemoSummary(report);
|
||||
|
||||
expect(replay).toMatchObject({
|
||||
runId: 'run-full',
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
adapter: DEMO_ADAPTER,
|
||||
status: 'done',
|
||||
});
|
||||
expect(summary).toContain('Full demo ingest: done');
|
||||
expect(summary).toContain('Saved memory: 1 wiki, 1 semantic layer');
|
||||
expect(summary).toContain('Provenance rows: 2');
|
||||
expect(summary).toContain('Next: klo setup demo inspect');
|
||||
expect(summary).toContain('Shows the files, semantic-layer sources, and memory KLO just produced.');
|
||||
expect(summary).toContain('Next: klo setup demo replay');
|
||||
expect(summary).toContain('Replays the same visual story without calling the LLM again.');
|
||||
expect(summary).not.toContain('--viz');
|
||||
});
|
||||
});
|
||||
213
packages/cli/src/demo-full.ts
Normal file
213
packages/cli/src/demo-full.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { resolveKloConfigReference } from '@klo/context/core';
|
||||
import {
|
||||
createMemoryFlowLiveBuffer,
|
||||
ingestReportToMemoryFlowReplay,
|
||||
runLocalIngest,
|
||||
type IngestReportSnapshot,
|
||||
type LocalIngestResult,
|
||||
type MemoryFlowReplayInput,
|
||||
type RunLocalIngestOptions,
|
||||
} from '@klo/context/ingest';
|
||||
import { loadKloProject, type KloLocalProject } from '@klo/context/project';
|
||||
import { runLocalScan, type LocalScanRunResult } from '@klo/context/scan';
|
||||
import { DEMO_ADAPTER, DEMO_CONNECTION_ID, DEMO_FULL_JOB_ID, ensureDemoProject } from './demo-assets.js';
|
||||
import { runDemoScan } from './demo-scan.js';
|
||||
import { createKloCliLocalIngestAdapters } from './local-adapters.js';
|
||||
import { formatNextStepLines } from './next-steps.js';
|
||||
|
||||
interface DemoFullOptions {
|
||||
projectDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runLocalScan?: typeof runLocalScan;
|
||||
runLocalIngest?: typeof runLocalIngest;
|
||||
onMemoryFlowChange?: (snapshot: MemoryFlowReplayInput) => void;
|
||||
}
|
||||
|
||||
export interface DemoFullResult {
|
||||
project: KloLocalProject;
|
||||
scan: LocalScanRunResult;
|
||||
ingest: LocalIngestResult;
|
||||
report: IngestReportSnapshot;
|
||||
replay: MemoryFlowReplayInput;
|
||||
}
|
||||
|
||||
type FullDemoCredentialStatus =
|
||||
| { status: 'ready' }
|
||||
| { status: 'missing-anthropic-key' }
|
||||
| { status: 'unsupported-provider'; provider: string };
|
||||
|
||||
async function ensureDemoProjectForReuse(projectDir: string): Promise<void> {
|
||||
await ensureDemoProject({ projectDir, force: false }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes('Demo project already exists')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function savedCounts(report: IngestReportSnapshot): { wikiCount: number; slCount: number } {
|
||||
const actions = report.body.workUnits.flatMap((workUnit) => workUnit.actions);
|
||||
return {
|
||||
wikiCount: actions.filter((action) => action.target === 'wiki').length,
|
||||
slCount: actions.filter((action) => action.target === 'sl').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function fullDemoCredentialStatus(
|
||||
project: KloLocalProject,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): FullDemoCredentialStatus {
|
||||
const llm = project.config.llm;
|
||||
if (llm.provider.backend === 'none') {
|
||||
return { status: 'unsupported-provider', provider: llm.provider.backend };
|
||||
}
|
||||
|
||||
if (llm.provider.backend === 'anthropic' && !resolveKloConfigReference(llm.provider.anthropic?.api_key, env)) {
|
||||
return { status: 'missing-anthropic-key' };
|
||||
}
|
||||
|
||||
return { status: 'ready' };
|
||||
}
|
||||
|
||||
export function assertFullDemoCredentials(project: KloLocalProject, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const llm = project.config.llm;
|
||||
const status = fullDemoCredentialStatus(project, env);
|
||||
if (status.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'unsupported-provider') {
|
||||
throw new Error(
|
||||
'klo setup demo --mode full requires llm.provider.backend: anthropic, vertex, or gateway. Run `klo setup demo init --force --no-input` to recreate the demo config, or run `klo setup demo --mode seeded --no-input` without credentials.',
|
||||
);
|
||||
}
|
||||
|
||||
if (llm.provider.backend === 'anthropic') {
|
||||
throw new Error(
|
||||
'klo setup demo --mode full needs ANTHROPIC_API_KEY. Export ANTHROPIC_API_KEY and rerun `klo setup demo --mode full --no-input`, or run `klo setup demo --mode seeded --no-input` without credentials.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFullDemoReplay(report: IngestReportSnapshot): MemoryFlowReplayInput {
|
||||
return ingestReportToMemoryFlowReplay(report, { provenanceRowCount: report.body.provenanceRows.length });
|
||||
}
|
||||
|
||||
function initialFullReplay(projectDir: string): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: DEMO_FULL_JOB_ID,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
adapter: DEMO_ADAPTER,
|
||||
status: 'running',
|
||||
sourceDir: `${projectDir}/raw-sources/${DEMO_CONNECTION_ID}/${DEMO_ADAPTER}`,
|
||||
syncId: 'pending',
|
||||
errors: [],
|
||||
events: [],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDemoFull(options: DemoFullOptions): Promise<DemoFullResult> {
|
||||
await ensureDemoProjectForReuse(options.projectDir);
|
||||
const project = await loadKloProject({ projectDir: options.projectDir });
|
||||
assertFullDemoCredentials(project, options.env);
|
||||
|
||||
const { result: scan } = await runDemoScan({
|
||||
projectDir: project.projectDir,
|
||||
jobId: 'demo-full-scan',
|
||||
...(options.runLocalScan ? { runLocalScan: options.runLocalScan } : {}),
|
||||
});
|
||||
|
||||
const memoryFlow = options.onMemoryFlowChange
|
||||
? createMemoryFlowLiveBuffer(initialFullReplay(project.projectDir), { onChange: options.onMemoryFlowChange })
|
||||
: undefined;
|
||||
const executeLocalIngest = options.runLocalIngest ?? runLocalIngest;
|
||||
const ingest = await executeLocalIngest({
|
||||
project,
|
||||
adapters: createKloCliLocalIngestAdapters(project),
|
||||
adapter: DEMO_ADAPTER,
|
||||
connectionId: DEMO_CONNECTION_ID,
|
||||
trigger: 'manual_resync',
|
||||
jobId: DEMO_FULL_JOB_ID,
|
||||
...(memoryFlow ? { memoryFlow } : {}),
|
||||
} satisfies RunLocalIngestOptions);
|
||||
|
||||
return {
|
||||
project,
|
||||
scan,
|
||||
ingest,
|
||||
report: ingest.report,
|
||||
replay: buildFullDemoReplay(ingest.report),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatFullDemoSummary(report: IngestReportSnapshot): string {
|
||||
const counts = savedCounts(report);
|
||||
return [
|
||||
'Full demo ingest: done',
|
||||
`Report: ${report.id}`,
|
||||
`Run: ${report.runId}`,
|
||||
`Job: ${report.jobId}`,
|
||||
`Sync: ${report.body.syncId}`,
|
||||
`Saved memory: ${counts.wikiCount} wiki, ${counts.slCount} semantic layer`,
|
||||
`Provenance rows: ${report.body.provenanceRows.length}`,
|
||||
'Next: klo setup demo inspect',
|
||||
' Shows the files, semantic-layer sources, and memory KLO just produced.',
|
||||
'Next: klo setup demo replay',
|
||||
' Replays the same visual story without calling the LLM again.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const ADAPTER_PREFIXES = ['live_database_', 'metabase_', 'looker_', 'lookml_', 'metricflow_', 'notion_', 'historic_sql_', 'dbt_descriptions_'];
|
||||
|
||||
function humanizeUnitKeyForReport(unitKey: string): string {
|
||||
let key = unitKey.replace(/-/g, '_');
|
||||
for (const prefix of ADAPTER_PREFIXES) {
|
||||
if (key.startsWith(prefix)) { key = key.slice(prefix.length); break; }
|
||||
}
|
||||
return key.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function formatCleanDemoSummary(report: IngestReportSnapshot, projectDir: string): string {
|
||||
const counts = savedCounts(report);
|
||||
const workUnits = report.body.workUnits;
|
||||
const conflictCount = report.body.conflictsResolved.length;
|
||||
const areasAnalyzed = workUnits.filter((wu) => wu.actions.length > 0).length;
|
||||
|
||||
const lines: string[] = ['', '★ KLO finished ingesting your data', ''];
|
||||
|
||||
if (areasAnalyzed > 0) {
|
||||
lines.push(` ✓ Analyzed ${areasAnalyzed} business area${areasAnalyzed === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (!report.body.reconciliationSkipped) {
|
||||
lines.push(` ✓ Reconciled — ${conflictCount > 0 ? `${conflictCount} conflict${conflictCount === 1 ? '' : 's'} resolved` : 'no conflicts'}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
if (counts.slCount > 0 || counts.wikiCount > 0) {
|
||||
lines.push(' KLO created:');
|
||||
if (counts.slCount > 0) lines.push(` 📊 ${counts.slCount} query definition${counts.slCount === 1 ? '' : 's'} — so agents can write accurate SQL for your data`);
|
||||
if (counts.wikiCount > 0) lines.push(` 📝 ${counts.wikiCount} knowledge page${counts.wikiCount === 1 ? '' : 's'} — so agents understand your business context`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const memoryFlow = report.body.memoryFlow;
|
||||
if (memoryFlow) {
|
||||
for (const detail of memoryFlow.details.actions) {
|
||||
if (!detail.summary) continue;
|
||||
const icon = detail.target === 'sl' ? '📊' : '📝';
|
||||
lines.push(` ${icon} ${detail.summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' What to do next:');
|
||||
lines.push(...formatNextStepLines());
|
||||
lines.push('');
|
||||
lines.push(` Your KLO project files are at: ${projectDir}`);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
127
packages/cli/src/demo-interaction.test.ts
Normal file
127
packages/cli/src/demo-interaction.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ensureDemoProject } from './demo-assets.js';
|
||||
import {
|
||||
chooseDemoProjectForInteractiveRun,
|
||||
createTestDemoPromptAdapter,
|
||||
resolveFullCredentialDecision,
|
||||
} from './demo-interaction.js';
|
||||
|
||||
function io(isTTY: boolean) {
|
||||
return {
|
||||
stdin: { isTTY },
|
||||
stdout: { isTTY, write: vi.fn() },
|
||||
stderr: { write: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
describe('demo interaction decisions', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'klo-demo-interaction-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reuses a valid project without prompting in no-input mode', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
io: io(false),
|
||||
prompts: createTestDemoPromptAdapter({ choices: [] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'use', projectDir: tempDir, reset: false });
|
||||
});
|
||||
|
||||
it('fails corrupted projects in no-input mode with reset guidance', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
await rm(join(tempDir, 'demo.db'), { force: true });
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
inputMode: 'disabled',
|
||||
io: io(false),
|
||||
prompts: createTestDemoPromptAdapter({ choices: [] }),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Demo project is not ready at ${tempDir}: missing demo.db. Run klo setup demo reset --project-dir ${tempDir} --force --no-input`,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets interactive users reset a corrupted project', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
await rm(join(tempDir, 'demo.db'), { force: true });
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
io: io(true),
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['reset'], confirms: [true] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'use', projectDir: tempDir, reset: true });
|
||||
});
|
||||
|
||||
it('lets interactive users choose another project directory', async () => {
|
||||
await ensureDemoProject({ projectDir: tempDir, force: false });
|
||||
const otherDir = join(tempDir, 'other-demo');
|
||||
|
||||
await expect(
|
||||
chooseDemoProjectForInteractiveRun({
|
||||
projectDir: tempDir,
|
||||
io: io(true),
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['other'], texts: [otherDir] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'use', projectDir: otherDir, reset: false });
|
||||
});
|
||||
|
||||
it('uses a pasted Anthropic key only for the returned process env', async () => {
|
||||
// pragma: allowlist secret
|
||||
const prompts = createTestDemoPromptAdapter({ choices: ['process_key'], passwords: ['sk-ant-process'] });
|
||||
|
||||
await expect(
|
||||
resolveFullCredentialDecision({
|
||||
needsAnthropicKey: true,
|
||||
inputMode: 'auto',
|
||||
io: io(true),
|
||||
env: {},
|
||||
prompts,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: 'full',
|
||||
env: { ANTHROPIC_API_KEY: 'sk-ant-process' }, // pragma: allowlist secret
|
||||
});
|
||||
});
|
||||
|
||||
it('lets interactive users explicitly choose seeded mode when the key is missing', async () => {
|
||||
await expect(
|
||||
resolveFullCredentialDecision({
|
||||
needsAnthropicKey: true,
|
||||
inputMode: 'auto',
|
||||
io: io(true),
|
||||
env: {},
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'run-mode', mode: 'seeded' });
|
||||
});
|
||||
|
||||
it('does not prompt when input is disabled', async () => {
|
||||
await expect(
|
||||
resolveFullCredentialDecision({
|
||||
needsAnthropicKey: true,
|
||||
inputMode: 'disabled',
|
||||
io: io(false),
|
||||
env: {},
|
||||
prompts: createTestDemoPromptAdapter({ choices: ['seeded'] }),
|
||||
}),
|
||||
).resolves.toEqual({ action: 'full', env: {} });
|
||||
});
|
||||
});
|
||||
202
packages/cli/src/demo-interaction.ts
Normal file
202
packages/cli/src/demo-interaction.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { cancel, confirm, isCancel, password, select, text } from '@clack/prompts';
|
||||
import type { Option as ClackOption } from '@clack/prompts';
|
||||
import { resolve } from 'node:path';
|
||||
import { inspectDemoProjectState } from './demo-assets.js';
|
||||
import type { KloDemoInputMode } from './demo.js';
|
||||
import { withMenuOptionsSpacing } from './prompt-navigation.js';
|
||||
|
||||
type DemoPromptOption<T extends string> = ClackOption<T>;
|
||||
|
||||
export interface DemoPromptAdapter {
|
||||
select<T extends string>(options: { message: string; options: Array<DemoPromptOption<T>> }): Promise<T>;
|
||||
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
|
||||
password(options: { message: string }): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string }): Promise<string>;
|
||||
cancel(message: string): void;
|
||||
}
|
||||
|
||||
interface DemoInteractiveIo {
|
||||
stdin?: { isTTY?: boolean };
|
||||
stdout: { isTTY?: boolean };
|
||||
}
|
||||
|
||||
type DemoProjectDecision =
|
||||
| { action: 'use'; projectDir: string; reset: boolean }
|
||||
| { action: 'cancel' };
|
||||
|
||||
type FullCredentialDecision =
|
||||
| { action: 'full'; env: NodeJS.ProcessEnv }
|
||||
| { action: 'run-mode'; mode: 'seeded' | 'replay' }
|
||||
| { action: 'cancel' };
|
||||
|
||||
function isInteractive(inputMode: KloDemoInputMode | undefined, io: DemoInteractiveIo): boolean {
|
||||
return inputMode !== 'disabled' && io.stdin?.isTTY === true && io.stdout.isTTY === true;
|
||||
}
|
||||
|
||||
function cloneEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return { ...env };
|
||||
}
|
||||
|
||||
function ensureNotCancelled<T>(value: T | symbol, prompts: Pick<DemoPromptAdapter, 'cancel'>): T {
|
||||
if (isCancel(value)) {
|
||||
prompts.cancel('Demo cancelled.');
|
||||
throw new Error('Demo cancelled.');
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function createClackDemoPromptAdapter(): DemoPromptAdapter {
|
||||
return {
|
||||
async select<T extends string>(options: { message: string; options: Array<DemoPromptOption<T>> }): Promise<T> {
|
||||
return ensureNotCancelled(await select(withMenuOptionsSpacing(options)), this);
|
||||
},
|
||||
async confirm(options: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
return ensureNotCancelled(await confirm(options), this);
|
||||
},
|
||||
async password(options: { message: string }): Promise<string> {
|
||||
return ensureNotCancelled(await password(options), this);
|
||||
},
|
||||
async text(options: { message: string; placeholder?: string }): Promise<string> {
|
||||
return ensureNotCancelled(await text(options), this);
|
||||
},
|
||||
cancel(message: string): void {
|
||||
cancel(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestDemoPromptAdapter(options: {
|
||||
choices?: string[];
|
||||
confirms?: boolean[];
|
||||
passwords?: string[];
|
||||
texts?: string[];
|
||||
}): DemoPromptAdapter {
|
||||
const choices = [...(options.choices ?? [])];
|
||||
const confirms = [...(options.confirms ?? [])];
|
||||
const passwords = [...(options.passwords ?? [])];
|
||||
const texts = [...(options.texts ?? [])];
|
||||
|
||||
return {
|
||||
async select<T extends string>(): Promise<T> {
|
||||
return choices.shift() as T;
|
||||
},
|
||||
async confirm(): Promise<boolean> {
|
||||
return confirms.shift() ?? false;
|
||||
},
|
||||
async password(): Promise<string> {
|
||||
return passwords.shift() ?? '';
|
||||
},
|
||||
async text(): Promise<string> {
|
||||
return texts.shift() ?? '';
|
||||
},
|
||||
cancel(): void {
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function chooseDemoProjectForInteractiveRun(options: {
|
||||
projectDir: string;
|
||||
inputMode?: KloDemoInputMode;
|
||||
io: DemoInteractiveIo;
|
||||
prompts?: DemoPromptAdapter;
|
||||
}): Promise<DemoProjectDecision> {
|
||||
const prompts = options.prompts ?? createClackDemoPromptAdapter();
|
||||
const projectDir = resolve(options.projectDir);
|
||||
const state = await inspectDemoProjectState(projectDir);
|
||||
|
||||
if (!isInteractive(options.inputMode, options.io)) {
|
||||
if (state.status === 'corrupt') {
|
||||
throw new Error(
|
||||
`Demo project is not ready at ${projectDir}: missing ${state.missing.join(', ')}. Run klo setup demo reset --project-dir ${projectDir} --force --no-input`,
|
||||
);
|
||||
}
|
||||
return { action: 'use', projectDir, reset: false };
|
||||
}
|
||||
|
||||
if (state.status === 'missing') {
|
||||
return { action: 'use', projectDir, reset: false };
|
||||
}
|
||||
|
||||
const choices =
|
||||
state.status === 'ready'
|
||||
? [
|
||||
{ value: 'reuse', label: 'Reuse existing demo project' },
|
||||
{ value: 'reset', label: 'Reset demo project' },
|
||||
{ value: 'other', label: 'Choose another directory' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
]
|
||||
: [
|
||||
{ value: 'reset', label: 'Reset corrupted demo project', hint: `Missing ${state.missing.join(', ')}` },
|
||||
{ value: 'other', label: 'Choose another directory' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
];
|
||||
|
||||
const choice = await prompts.select({
|
||||
message: state.status === 'ready' ? `Demo project exists at ${projectDir}` : `Demo project is not ready at ${projectDir}`,
|
||||
options: choices,
|
||||
});
|
||||
|
||||
if (choice === 'cancel') {
|
||||
prompts.cancel('Demo cancelled.');
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
if (choice === 'other') {
|
||||
const nextProjectDir = await prompts.text({
|
||||
message: 'Demo project directory',
|
||||
placeholder: projectDir,
|
||||
});
|
||||
return { action: 'use', projectDir: resolve(nextProjectDir), reset: false };
|
||||
}
|
||||
|
||||
if (choice === 'reset') {
|
||||
const confirmed = await prompts.confirm({
|
||||
message: `Recreate ${projectDir}? Existing demo artifacts under that directory will be removed.`,
|
||||
initialValue: false,
|
||||
});
|
||||
return confirmed ? { action: 'use', projectDir, reset: true } : { action: 'cancel' };
|
||||
}
|
||||
|
||||
return { action: 'use', projectDir, reset: false };
|
||||
}
|
||||
|
||||
export async function resolveFullCredentialDecision(options: {
|
||||
needsAnthropicKey: boolean;
|
||||
inputMode?: KloDemoInputMode;
|
||||
io: DemoInteractiveIo;
|
||||
env: NodeJS.ProcessEnv;
|
||||
prompts?: DemoPromptAdapter;
|
||||
}): Promise<FullCredentialDecision> {
|
||||
const env = cloneEnv(options.env);
|
||||
if (!options.needsAnthropicKey || env.ANTHROPIC_API_KEY) {
|
||||
return { action: 'full', env };
|
||||
}
|
||||
|
||||
if (!isInteractive(options.inputMode, options.io)) {
|
||||
return { action: 'full', env };
|
||||
}
|
||||
|
||||
const prompts = options.prompts ?? createClackDemoPromptAdapter();
|
||||
const choice = await prompts.select({
|
||||
message: 'Anthropic credentials are missing for the full demo',
|
||||
options: [
|
||||
{ value: 'process_key', label: 'Enter key for this process only' },
|
||||
{ value: 'seeded', label: 'Run pre-seeded demo without LLM' },
|
||||
{ value: 'replay', label: 'Run packaged replay' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
],
|
||||
});
|
||||
|
||||
if (choice === 'cancel') {
|
||||
prompts.cancel('Demo cancelled.');
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
if (choice === 'seeded' || choice === 'replay') {
|
||||
return { action: 'run-mode', mode: choice };
|
||||
}
|
||||
|
||||
const key = await prompts.password({ message: 'ANTHROPIC_API_KEY' });
|
||||
return { action: 'full', env: { ...env, ANTHROPIC_API_KEY: key } };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue