chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm
* refactor(workspace): rewrite @ktx/llm imports to relative paths
* refactor(workspace): fold internal packages into cli
* chore(workspace): gate dead-code with knip production mode
Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.
* refactor(cli): delete internal barrel index.ts files
The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).
This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
(the published package entry).
- Rewrites ~270 source/test files to import each name directly from
the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
`create-warehouse-verification-tools.ts` (the function it defined
locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
live-database/extracted-schema, live-database/structural-sync,
relationship-* feedback/review chain) plus their tests and a
cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
(notion-client, connector barrels in scan/local-scan-connectors
tests) to mock the source files instead.
- Points the maintainer benchmark script
(`scripts/relationship-benchmark-report.mjs`) at source files
instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
production entries only for the benchmark code reached via dist by
the maintainer script.
Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.
`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.
* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly
Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.
Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.
* docs: align "agent clients" and "data agents" terminology
Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.
Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.
* refactor(release): single source of truth for package version
Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.
Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.
- update-public-release-version.mjs rewrites both Python pyproject.toml
files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
@semantic-release/git assets so the release commit back to main
carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
replaced with "?? getKtxCliPackageInfo().version", and
createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
always reflects the most recent release; no sentinel pin to
maintain.
Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.
* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime
Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.
* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal
Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.
* fix(cli): use real package metadata in print-command-tree
The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.
* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts
Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.
Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
|
|
|
import { buildMemoryFlowViewModel } from './context/ingest/memory-flow/view-model.js';
|
|
|
|
|
import { createMemoryFlowLiveBuffer, sanitizeMemoryFlowError } from './context/ingest/memory-flow/live-buffer.js';
|
|
|
|
|
import { formatMemoryFlowFinalSummary } from './context/ingest/memory-flow/summary.js';
|
|
|
|
|
import { getLatestLocalIngestStatus, getLocalIngestStatus, type LocalMetabaseFanoutResult, type LocalMetabaseFanoutProgress, type RunLocalIngestOptions, runLocalIngest, runLocalMetabaseIngest } from './context/ingest/local-ingest.js';
|
2026-05-30 00:42:59 +02:00
|
|
|
import { type IngestReportSnapshot, ingestReportOutcome, savedMemoryCountsForReport } from './context/ingest/reports.js';
|
chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm
* refactor(workspace): rewrite @ktx/llm imports to relative paths
* refactor(workspace): fold internal packages into cli
* chore(workspace): gate dead-code with knip production mode
Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.
* refactor(cli): delete internal barrel index.ts files
The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).
This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
(the published package entry).
- Rewrites ~270 source/test files to import each name directly from
the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
`create-warehouse-verification-tools.ts` (the function it defined
locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
live-database/extracted-schema, live-database/structural-sync,
relationship-* feedback/review chain) plus their tests and a
cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
(notion-client, connector barrels in scan/local-scan-connectors
tests) to mock the source files instead.
- Points the maintainer benchmark script
(`scripts/relationship-benchmark-report.mjs`) at source files
instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
production entries only for the benchmark code reached via dist by
the maintainer script.
Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.
`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.
* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly
Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.
Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.
* docs: align "agent clients" and "data agents" terminology
Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.
Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.
* refactor(release): single source of truth for package version
Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.
Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.
- update-public-release-version.mjs rewrites both Python pyproject.toml
files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
@semantic-release/git assets so the release commit back to main
carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
replaced with "?? getKtxCliPackageInfo().version", and
createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
always reflects the most recent release; no sentinel pin to
maintain.
Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.
* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime
Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.
* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal
Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.
* fix(cli): use real package metadata in print-command-tree
The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.
* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts
Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.
Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
|
|
|
import { ingestReportToMemoryFlowReplay } from './context/ingest/memory-flow/events.js';
|
|
|
|
|
import type { MemoryFlowEvent, MemoryFlowReplayInput } from './context/ingest/memory-flow/types.js';
|
|
|
|
|
import { renderMemoryFlowReplay } from './context/ingest/memory-flow/render.js';
|
|
|
|
|
import type { KtxSqlQueryExecutorPort } from './context/connections/query-executor.js';
|
|
|
|
|
import { loadKtxProject, type KtxLocalProject } from './context/project/project.js';
|
|
|
|
|
import { getKtxCliPackageInfo } from './cli-runtime.js';
|
2026-05-21 02:21:22 +02:00
|
|
|
import { resolveProjectEmbeddingProvider } from './embedding-resolution.js';
|
2026-05-13 13:43:23 +02:00
|
|
|
import { createKtxCliIngestQueryExecutor } from './ingest-query-executor.js';
|
2026-05-10 23:12:26 +02:00
|
|
|
import { readIngestReportSnapshotFile } from './ingest-report-file.js';
|
2026-05-12 11:26:34 +02:00
|
|
|
import { createCliOperationalLogger } from './io/logger.js';
|
fix: read semantic sources safely (#284)
* fix: read semantic sources safely
* test: retarget reindex per-scope error case to a broken manifest
Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.
* fix(sl): decouple semantic-layer file names from warehouse naming rules
The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.
- New `source-files.ts`: one total filename derivation (safe lowercase
names verbatim; otherwise slug + sha256-hash suffix, immune to
case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
`assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
name (human renames survive); new sources get a derived filename; a
derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
caller to launder IO errors; `deleteSource` distinguishes manifest-backed
sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
now a recommendation for new sources) and rejects sourceName/source.name
mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
derivation use resolved paths / in-file names instead of interpolating
`<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
helpers onto the shared module; dropped dead service methods.
* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator
- Key broken/renamed semantic-layer files by their recoverable in-file
name (slSourceNameForFile) so mid-edit sources stay reachable under
their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
recover deleted files' declared names from the pre-change commit
instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
validateReadOnly in the connection's dialect, sharing one
driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
reject smuggled second statements, and strip trailing
semicolons/comments before row-limit wrapping
2026-06-10 14:06:13 +02:00
|
|
|
import { createKtxCliLocalIngestAdapters, resolveKtxCliSqlAnalysis } from './local-adapters.js';
|
2026-05-11 15:50:34 +02:00
|
|
|
import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
|
2026-05-10 23:51:24 +02:00
|
|
|
import { type KtxMemoryFlowStdin, renderMemoryFlowInteractively } from './memory-flow-interactive.js';
|
2026-05-10 23:12:26 +02:00
|
|
|
import {
|
2026-05-10 23:51:24 +02:00
|
|
|
type KtxMemoryFlowTuiIo,
|
2026-05-10 23:12:26 +02:00
|
|
|
type MemoryFlowTuiLiveSession,
|
|
|
|
|
renderMemoryFlowTui,
|
|
|
|
|
startLiveMemoryFlowTui,
|
|
|
|
|
} from './memory-flow-tui.js';
|
|
|
|
|
import { resolveVizFallback, warnVizFallbackOnce } from './viz-fallback.js';
|
|
|
|
|
import { profileMark } from './startup-profile.js';
|
|
|
|
|
|
|
|
|
|
profileMark('module:ingest');
|
|
|
|
|
|
2026-05-14 01:43:06 +02:00
|
|
|
type KtxIngestOutputMode = 'plain' | 'json' | 'viz';
|
2026-05-10 23:51:24 +02:00
|
|
|
type KtxIngestInputMode = 'auto' | 'disabled';
|
2026-05-10 23:12:26 +02:00
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
export type KtxIngestArgs =
|
2026-05-10 23:12:26 +02:00
|
|
|
| {
|
|
|
|
|
command: 'run';
|
|
|
|
|
projectDir: string;
|
|
|
|
|
connectionId: string;
|
|
|
|
|
adapter: string;
|
|
|
|
|
sourceDir?: string;
|
|
|
|
|
databaseIntrospectionUrl?: string;
|
2026-05-11 15:50:34 +02:00
|
|
|
cliVersion?: string;
|
|
|
|
|
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
|
2026-05-10 23:12:26 +02:00
|
|
|
debugLlmRequestFile?: string;
|
2026-05-14 01:43:06 +02:00
|
|
|
allowImplicitAdapter?: boolean;
|
|
|
|
|
historicSqlPullConfigOverride?: Record<string, unknown>;
|
2026-05-10 23:51:24 +02:00
|
|
|
outputMode: KtxIngestOutputMode;
|
|
|
|
|
inputMode?: KtxIngestInputMode;
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
|
|
|
|
| {
|
|
|
|
|
command: 'status' | 'replay' | 'watch';
|
|
|
|
|
projectDir: string;
|
|
|
|
|
runId?: string;
|
|
|
|
|
reportFile?: string;
|
2026-05-10 23:51:24 +02:00
|
|
|
outputMode: KtxIngestOutputMode;
|
|
|
|
|
inputMode?: KtxIngestInputMode;
|
2026-05-10 23:12:26 +02:00
|
|
|
};
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
interface KtxIngestIo {
|
|
|
|
|
stdin?: KtxMemoryFlowStdin;
|
2026-05-10 23:12:26 +02:00
|
|
|
stdout: { isTTY?: boolean; columns?: number; write(chunk: string): void };
|
|
|
|
|
stderr: { write(chunk: string): void };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 17:01:48 +02:00
|
|
|
export interface KtxIngestProgressUpdate {
|
|
|
|
|
percent: number;
|
|
|
|
|
message: string;
|
|
|
|
|
transient?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface KtxIngestDeps {
|
2026-05-10 23:12:26 +02:00
|
|
|
jobIdFactory?: () => string;
|
|
|
|
|
now?: () => Date;
|
2026-05-10 23:51:24 +02:00
|
|
|
createAdapters?: typeof createKtxCliLocalIngestAdapters;
|
2026-05-13 13:43:23 +02:00
|
|
|
createQueryExecutor?: (project: KtxLocalProject) => KtxSqlQueryExecutorPort;
|
chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm
* refactor(workspace): rewrite @ktx/llm imports to relative paths
* refactor(workspace): fold internal packages into cli
* chore(workspace): gate dead-code with knip production mode
Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.
* refactor(cli): delete internal barrel index.ts files
The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).
This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
(the published package entry).
- Rewrites ~270 source/test files to import each name directly from
the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
`create-warehouse-verification-tools.ts` (the function it defined
locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
live-database/extracted-schema, live-database/structural-sync,
relationship-* feedback/review chain) plus their tests and a
cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
(notion-client, connector barrels in scan/local-scan-connectors
tests) to mock the source files instead.
- Points the maintainer benchmark script
(`scripts/relationship-benchmark-report.mjs`) at source files
instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
production entries only for the benchmark code reached via dist by
the maintainer script.
Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.
`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.
* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly
Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.
Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.
* docs: align "agent clients" and "data agents" terminology
Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.
Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.
* refactor(release): single source of truth for package version
Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.
Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.
- update-public-release-version.mjs rewrites both Python pyproject.toml
files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
@semantic-release/git assets so the release commit back to main
carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
replaced with "?? getKtxCliPackageInfo().version", and
createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
always reflects the most recent release; no sentinel pin to
maintain.
Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.
* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime
Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.
* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal
Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.
* fix(cli): use real package metadata in print-command-tree
The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.
* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts
Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.
Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
|
|
|
resolveEmbeddingProvider?: typeof resolveProjectEmbeddingProvider;
|
2026-05-10 23:12:26 +02:00
|
|
|
runLocalIngest?: typeof runLocalIngest;
|
|
|
|
|
runLocalMetabaseIngest?: typeof runLocalMetabaseIngest;
|
|
|
|
|
readReportFile?: typeof readIngestReportSnapshotFile;
|
|
|
|
|
renderStoredMemoryFlow?: typeof renderMemoryFlowTui;
|
|
|
|
|
startLiveMemoryFlow?: typeof startLiveMemoryFlowTui;
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
abortSignal?: AbortSignal;
|
2026-05-10 23:12:26 +02:00
|
|
|
env?: NodeJS.ProcessEnv;
|
2026-05-16 12:06:34 +02:00
|
|
|
localIngestOptions?: Pick<
|
|
|
|
|
RunLocalIngestOptions,
|
|
|
|
|
| 'agentRunner'
|
|
|
|
|
| 'llmRuntime'
|
|
|
|
|
| 'memoryModel'
|
2026-05-10 23:12:26 +02:00
|
|
|
| 'semanticLayerCompute'
|
|
|
|
|
| 'queryExecutor'
|
fix: read semantic sources safely (#284)
* fix: read semantic sources safely
* test: retarget reindex per-scope error case to a broken manifest
Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.
* fix(sl): decouple semantic-layer file names from warehouse naming rules
The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.
- New `source-files.ts`: one total filename derivation (safe lowercase
names verbatim; otherwise slug + sha256-hash suffix, immune to
case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
`assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
name (human renames survive); new sources get a derived filename; a
derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
caller to launder IO errors; `deleteSource` distinguishes manifest-backed
sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
now a recommendation for new sources) and rejects sourceName/source.name
mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
derivation use resolved paths / in-file names instead of interpolating
`<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
helpers onto the shared module; dropped dead service methods.
* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator
- Key broken/renamed semantic-layer files by their recoverable in-file
name (slSourceNameForFile) so mid-edit sources stay reachable under
their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
recover deleted files' declared names from the pre-change commit
instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
validateReadOnly in the connection's dialect, sharing one
driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
reject smuggled second statements, and strip trailing
semicolons/comments before row-limit wrapping
2026-06-10 14:06:13 +02:00
|
|
|
| 'sqlAnalysis'
|
2026-05-10 23:12:26 +02:00
|
|
|
| 'logger'
|
|
|
|
|
| 'pullConfigOptions'
|
|
|
|
|
>;
|
2026-05-13 17:01:48 +02:00
|
|
|
progress?: (update: KtxIngestProgressUpdate) => void;
|
2026-05-16 11:39:43 +02:00
|
|
|
runtimeIo?: KtxIngestIo;
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
|
|
|
|
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
function createCliAbortSignal(): { signal: AbortSignal; dispose: () => void } {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
let interrupted = false;
|
|
|
|
|
const onSigint = () => {
|
|
|
|
|
if (interrupted) {
|
|
|
|
|
process.exit(130);
|
|
|
|
|
}
|
|
|
|
|
interrupted = true;
|
|
|
|
|
controller.abort(new DOMException('Aborted', 'AbortError'));
|
|
|
|
|
};
|
|
|
|
|
process.on('SIGINT', onSigint);
|
|
|
|
|
return {
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
dispose: () => process.off('SIGINT', onSigint),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 01:43:06 +02:00
|
|
|
const REPORT_SOURCE_LABELS = new Map<string, string>([
|
|
|
|
|
['live-database', 'Database schema'],
|
|
|
|
|
['historic-sql', 'Query history'],
|
|
|
|
|
['dbt', 'dbt'],
|
|
|
|
|
['metricflow', 'MetricFlow'],
|
|
|
|
|
['lookml', 'LookML'],
|
|
|
|
|
['looker', 'Looker'],
|
|
|
|
|
['metabase', 'Metabase'],
|
|
|
|
|
['notion', 'Notion'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
function reportSourceLabel(sourceKey: string): string {
|
|
|
|
|
const label = REPORT_SOURCE_LABELS.get(sourceKey);
|
|
|
|
|
if (label) {
|
|
|
|
|
return label;
|
|
|
|
|
}
|
|
|
|
|
return sourceKey
|
|
|
|
|
.split(/[-_]+/)
|
|
|
|
|
.filter((part) => part.length > 0)
|
|
|
|
|
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
|
|
|
|
.join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 10:27:29 +02:00
|
|
|
function jsonObjectFromFailureReason(reason: string): Record<string, unknown> | null {
|
|
|
|
|
const trimmed = reason.trim();
|
|
|
|
|
const start = trimmed.indexOf('{');
|
|
|
|
|
const end = trimmed.lastIndexOf('}');
|
|
|
|
|
if (start < 0 || end < start) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const parsed: unknown = JSON.parse(trimmed.slice(start, end + 1));
|
|
|
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringField(record: Record<string, unknown>, key: string): string | null {
|
|
|
|
|
const value = record[key];
|
|
|
|
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isGoogleReauthFailure(record: Record<string, unknown>): boolean {
|
|
|
|
|
const error = stringField(record, 'error')?.toLowerCase() ?? '';
|
|
|
|
|
const description = stringField(record, 'error_description')?.toLowerCase() ?? '';
|
|
|
|
|
const subtype = stringField(record, 'error_subtype')?.toLowerCase() ?? '';
|
|
|
|
|
return error === 'invalid_grant' && (description.includes('reauth') || subtype === 'invalid_rapt');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatFailureReason(sourceKey: string, reason: string): string {
|
|
|
|
|
const parsed = jsonObjectFromFailureReason(reason);
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
return sanitizeMemoryFlowError(reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sourceKey === 'historic-sql' && isGoogleReauthFailure(parsed)) {
|
|
|
|
|
return 'Google Cloud authentication failed while analyzing query history: application-default credentials expired or require reauthentication (invalid_grant / invalid_rapt). Run `gcloud auth application-default login`, then retry.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const error = stringField(parsed, 'error');
|
|
|
|
|
const description = stringField(parsed, 'error_description');
|
|
|
|
|
const subtype = stringField(parsed, 'error_subtype');
|
|
|
|
|
const parts = [error, description].filter((part): part is string => Boolean(part));
|
|
|
|
|
const message = parts.length > 0 ? parts.join(': ') : reason;
|
|
|
|
|
return subtype ? `${message} (${subtype})` : message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function failedReportMessage(report: IngestReportSnapshot): string | null {
|
2026-05-18 13:38:06 +02:00
|
|
|
if (report.body.status === 'failed' && report.body.failure?.message) {
|
|
|
|
|
return sanitizeMemoryFlowError(report.body.failure.message);
|
|
|
|
|
}
|
2026-05-17 10:27:29 +02:00
|
|
|
const failedCount = report.body.failedWorkUnits.length;
|
|
|
|
|
if (failedCount === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const firstFailure = report.body.workUnits.find(
|
|
|
|
|
(workUnit) => workUnit.status === 'failed' && typeof workUnit.reason === 'string' && workUnit.reason.trim(),
|
|
|
|
|
);
|
|
|
|
|
const sourceLabel = reportSourceLabel(report.sourceKey);
|
|
|
|
|
const prefix = `${sourceLabel} failed for ${pluralize(failedCount, 'task')}.`;
|
|
|
|
|
if (!firstFailure?.reason) {
|
|
|
|
|
return prefix;
|
|
|
|
|
}
|
|
|
|
|
return `${prefix} First failure: ${formatFailureReason(report.sourceKey, firstFailure.reason)}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
function writeReportStatus(report: IngestReportSnapshot, io: KtxIngestIo): void {
|
2026-05-11 22:52:47 +02:00
|
|
|
const counts = savedMemoryCountsForReport(report);
|
2026-05-17 10:27:29 +02:00
|
|
|
const failedMessage = failedReportMessage(report);
|
2026-05-10 23:12:26 +02:00
|
|
|
io.stdout.write(`Report: ${report.id}\n`);
|
|
|
|
|
io.stdout.write(`Run: ${report.runId}\n`);
|
|
|
|
|
io.stdout.write(`Job: ${report.jobId}\n`);
|
2026-05-18 13:38:06 +02:00
|
|
|
if (report.body.tracePath) {
|
|
|
|
|
io.stdout.write(`Trace: ${report.body.tracePath}\n`);
|
|
|
|
|
}
|
2026-05-30 00:42:59 +02:00
|
|
|
io.stdout.write(`Status: ${ingestReportOutcome(report)}\n`);
|
2026-05-14 01:43:06 +02:00
|
|
|
io.stdout.write(`Source: ${reportSourceLabel(report.sourceKey)}\n`);
|
2026-05-10 23:12:26 +02:00
|
|
|
io.stdout.write(`Connection: ${report.connectionId}\n`);
|
|
|
|
|
io.stdout.write(`Sync: ${report.body.syncId}\n`);
|
|
|
|
|
io.stdout.write(
|
|
|
|
|
`Diff: +${report.body.diffSummary.added}/~${report.body.diffSummary.modified}/-${report.body.diffSummary.deleted}/=${report.body.diffSummary.unchanged}\n`,
|
|
|
|
|
);
|
2026-05-14 01:43:06 +02:00
|
|
|
io.stdout.write(`Tasks: ${report.body.workUnits.length}\n`);
|
2026-05-17 10:27:29 +02:00
|
|
|
if (report.body.failedWorkUnits.length > 0) {
|
|
|
|
|
io.stdout.write(`Failed tasks: ${report.body.failedWorkUnits.length}\n`);
|
|
|
|
|
}
|
|
|
|
|
if (failedMessage) {
|
|
|
|
|
io.stdout.write(`Error: ${failedMessage}\n`);
|
|
|
|
|
}
|
2026-05-10 23:12:26 +02:00
|
|
|
io.stdout.write(`Saved memory: ${counts.wikiCount} wiki, ${counts.slCount} SL\n`);
|
|
|
|
|
io.stdout.write(`Provenance rows: ${report.body.provenanceRows.length}\n`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
function writeMetabaseFanoutStatus(result: LocalMetabaseFanoutResult, io: KtxIngestIo): void {
|
2026-05-10 23:13:17 -07:00
|
|
|
const counts = result.children.reduce(
|
|
|
|
|
(acc, child) => {
|
2026-05-11 22:52:47 +02:00
|
|
|
const childCounts = savedMemoryCountsForReport(child.report);
|
2026-05-10 23:13:17 -07:00
|
|
|
return {
|
|
|
|
|
wikiCount: acc.wikiCount + childCounts.wikiCount,
|
|
|
|
|
slCount: acc.slCount + childCounts.slCount,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
{ wikiCount: 0, slCount: 0 },
|
|
|
|
|
);
|
2026-05-25 11:09:33 -04:00
|
|
|
io.stdout.write(`Metabase fanout: ${result.status}\n`);
|
2026-05-10 23:12:26 +02:00
|
|
|
io.stdout.write(`Source: ${result.metabaseConnectionId}\n`);
|
|
|
|
|
io.stdout.write(`Children: ${result.children.length}\n`);
|
|
|
|
|
if (result.totals) {
|
2026-05-14 01:43:06 +02:00
|
|
|
io.stdout.write(`Tasks: ${result.totals.workUnits}\n`);
|
|
|
|
|
io.stdout.write(`Failed tasks: ${result.totals.failedWorkUnits}\n`);
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
2026-05-10 23:13:17 -07:00
|
|
|
io.stdout.write(`Saved memory: ${counts.wikiCount} wiki, ${counts.slCount} SL\n`);
|
2026-05-10 23:12:26 +02:00
|
|
|
for (const child of result.children) {
|
2026-05-30 00:42:59 +02:00
|
|
|
const status = ingestReportOutcome(child.report);
|
2026-05-10 23:12:26 +02:00
|
|
|
io.stdout.write(
|
2026-05-10 23:13:17 -07:00
|
|
|
`- target=${child.targetConnectionId} database=${child.metabaseDatabaseId} status=${status} job=${child.jobId} report=${child.report.id}\n`,
|
2026-05-10 23:12:26 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
|
|
|
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createMetabaseFanoutProgress(
|
|
|
|
|
connectionId: string,
|
2026-05-10 23:51:24 +02:00
|
|
|
io: KtxIngestIo,
|
2026-05-13 17:01:48 +02:00
|
|
|
onProgress?: (update: KtxIngestProgressUpdate) => void,
|
2026-05-10 23:12:26 +02:00
|
|
|
): LocalMetabaseFanoutProgress {
|
2026-05-12 11:29:34 +02:00
|
|
|
io.stderr.write(`Metabase ingest: ${connectionId}\n`);
|
|
|
|
|
io.stderr.write('Checking mappings and scheduled-pull targets...\n');
|
2026-05-13 17:01:48 +02:00
|
|
|
onProgress?.({ percent: 5, message: `Checking Metabase mappings for ${connectionId}` });
|
2026-05-10 23:12:26 +02:00
|
|
|
return {
|
|
|
|
|
onMetabaseFanoutPlanned(event) {
|
2026-05-12 11:29:34 +02:00
|
|
|
io.stderr.write(`Targets: ${pluralize(event.children.length, 'mapped database')}\n`);
|
2026-05-13 17:01:48 +02:00
|
|
|
onProgress?.({
|
|
|
|
|
percent: 10,
|
|
|
|
|
message: `Metabase ${event.metabaseConnectionId}: ${pluralize(event.children.length, 'mapped database')}`,
|
|
|
|
|
});
|
2026-05-10 23:12:26 +02:00
|
|
|
for (const child of event.children) {
|
2026-05-12 11:29:34 +02:00
|
|
|
io.stderr.write(`- database=${child.metabaseDatabaseId} target=${child.targetConnectionId} status=queued\n`);
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onMetabaseChildStarted(event) {
|
2026-05-12 11:29:34 +02:00
|
|
|
io.stderr.write(
|
2026-05-10 23:12:26 +02:00
|
|
|
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=running job=${event.jobId}\n`,
|
|
|
|
|
);
|
2026-05-13 17:01:48 +02:00
|
|
|
onProgress?.({
|
|
|
|
|
percent: 25,
|
|
|
|
|
message: `Metabase database ${event.metabaseDatabaseId} -> ${event.targetConnectionId} running`,
|
|
|
|
|
});
|
2026-05-10 23:12:26 +02:00
|
|
|
},
|
|
|
|
|
onMetabaseChildCompleted(event) {
|
2026-05-12 11:29:34 +02:00
|
|
|
io.stderr.write(
|
2026-05-10 23:12:26 +02:00
|
|
|
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=${event.status} job=${event.jobId}\n`,
|
|
|
|
|
);
|
2026-05-13 17:01:48 +02:00
|
|
|
onProgress?.({
|
|
|
|
|
percent: 90,
|
|
|
|
|
message: `Metabase database ${event.metabaseDatabaseId} -> ${event.targetConnectionId} ${event.status}`,
|
|
|
|
|
});
|
2026-05-10 23:12:26 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 22:35:07 +02:00
|
|
|
function formatDiffProgress(event: Extract<MemoryFlowEvent, { type: 'diff_computed' }>): string {
|
|
|
|
|
return `+${event.added}/~${event.modified}/-${event.deleted}/=${event.unchanged}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 10:25:58 +02:00
|
|
|
function workUnitEventsThrough(snapshot: MemoryFlowReplayInput, eventIndex: number): MemoryFlowEvent[] {
|
2026-05-18 13:38:06 +02:00
|
|
|
const latestPlanIndex = snapshot.events
|
|
|
|
|
.slice(0, eventIndex + 1)
|
|
|
|
|
.findLastIndex((event) => event.type === 'chunks_planned');
|
|
|
|
|
const startIndex = latestPlanIndex >= 0 ? latestPlanIndex + 1 : 0;
|
|
|
|
|
return snapshot.events.slice(startIndex, eventIndex + 1);
|
2026-05-12 10:25:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function completedWorkUnitCountThrough(snapshot: MemoryFlowReplayInput, eventIndex: number): number {
|
|
|
|
|
return workUnitEventsThrough(snapshot, eventIndex).filter((event) => event.type === 'work_unit_finished').length;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 14:21:57 +02:00
|
|
|
function activeWorkUnitCountThrough(snapshot: MemoryFlowReplayInput, eventIndex: number): number {
|
|
|
|
|
const active = new Set<string>();
|
|
|
|
|
for (const event of workUnitEventsThrough(snapshot, eventIndex)) {
|
|
|
|
|
if (event.type === 'work_unit_started') {
|
|
|
|
|
active.add(event.unitKey);
|
|
|
|
|
}
|
|
|
|
|
if (event.type === 'work_unit_finished') {
|
|
|
|
|
active.delete(event.unitKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return active.size;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 10:25:58 +02:00
|
|
|
function plannedWorkUnitCountThrough(snapshot: MemoryFlowReplayInput, eventIndex: number): number {
|
|
|
|
|
if (snapshot.plannedWorkUnits.length > 0) {
|
|
|
|
|
return snapshot.plannedWorkUnits.length;
|
|
|
|
|
}
|
2026-05-18 13:38:06 +02:00
|
|
|
const planEvent = snapshot.events
|
|
|
|
|
.slice(0, eventIndex + 1)
|
2026-05-12 10:25:58 +02:00
|
|
|
.filter((event) => event.type === 'chunks_planned')
|
|
|
|
|
.at(-1);
|
|
|
|
|
return planEvent?.workUnitCount ?? completedWorkUnitCountThrough(snapshot, eventIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function workUnitOrdinalThrough(snapshot: MemoryFlowReplayInput, eventIndex: number, unitKey: string): number {
|
|
|
|
|
const events = workUnitEventsThrough(snapshot, eventIndex);
|
|
|
|
|
const startedIndex = events.findIndex((event) => event.type === 'work_unit_started' && event.unitKey === unitKey);
|
|
|
|
|
if (startedIndex === -1) {
|
|
|
|
|
return completedWorkUnitCountThrough(snapshot, eventIndex) + 1;
|
|
|
|
|
}
|
|
|
|
|
return events.slice(0, startedIndex + 1).filter((event) => event.type === 'work_unit_started').length;
|
2026-05-11 22:35:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function plainIngestEventProgress(
|
|
|
|
|
event: MemoryFlowEvent,
|
|
|
|
|
snapshot: MemoryFlowReplayInput,
|
2026-05-12 10:25:58 +02:00
|
|
|
eventIndex: number,
|
2026-05-12 14:21:57 +02:00
|
|
|
): { percent: number; message: string; transient?: boolean } | null {
|
2026-05-11 22:35:07 +02:00
|
|
|
switch (event.type) {
|
|
|
|
|
case 'source_acquired':
|
|
|
|
|
return {
|
|
|
|
|
percent: 15,
|
|
|
|
|
message: `Fetched ${pluralize(event.fileCount, 'source file')} from ${event.adapter}`,
|
|
|
|
|
};
|
|
|
|
|
case 'raw_snapshot_written':
|
|
|
|
|
return {
|
|
|
|
|
percent: 25,
|
|
|
|
|
message: `Wrote raw snapshot ${event.syncId} with ${pluralize(event.rawFileCount, 'file')}`,
|
|
|
|
|
};
|
|
|
|
|
case 'diff_computed':
|
|
|
|
|
return { percent: 35, message: `Computed source diff ${formatDiffProgress(event)}` };
|
|
|
|
|
case 'chunks_planned':
|
2026-05-13 17:01:48 +02:00
|
|
|
if (event.workUnitCount === 0) {
|
|
|
|
|
return {
|
|
|
|
|
percent: 80,
|
2026-05-14 01:43:06 +02:00
|
|
|
message: 'No tasks to process; finalizing ingest',
|
2026-05-13 17:01:48 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-05-11 22:35:07 +02:00
|
|
|
return {
|
|
|
|
|
percent: 45,
|
2026-05-14 01:43:06 +02:00
|
|
|
message: `Planned ${pluralize(event.workUnitCount, 'task')}`,
|
2026-05-11 22:35:07 +02:00
|
|
|
};
|
|
|
|
|
case 'stage_skipped':
|
|
|
|
|
return { percent: 45, message: `Skipped ${event.stage}: ${event.reason}` };
|
2026-05-18 13:38:06 +02:00
|
|
|
case 'stage_progress':
|
|
|
|
|
return {
|
|
|
|
|
percent: event.percent,
|
|
|
|
|
message: event.message,
|
|
|
|
|
...(event.transient !== undefined ? { transient: event.transient } : {}),
|
|
|
|
|
};
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
case 'rate_limit_wait':
|
|
|
|
|
return {
|
|
|
|
|
percent: 50,
|
|
|
|
|
message: `Rate-limited (${event.provider}${event.rateLimitType ? ` ${event.rateLimitType}` : ''}); resuming in ${Math.ceil(event.remainingMs / 1_000)}s`,
|
|
|
|
|
transient: true,
|
|
|
|
|
};
|
2026-05-12 10:25:58 +02:00
|
|
|
case 'work_unit_started': {
|
|
|
|
|
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
|
|
|
|
|
const ordinal = workUnitOrdinalThrough(snapshot, eventIndex, event.unitKey);
|
2026-05-14 01:43:06 +02:00
|
|
|
const progress = total > 0 ? `${ordinal}/${total} tasks: ` : '';
|
2026-05-12 10:25:58 +02:00
|
|
|
return { percent: 55, message: `Processing ${progress}${event.unitKey}` };
|
|
|
|
|
}
|
|
|
|
|
case 'work_unit_step': {
|
|
|
|
|
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
|
|
|
|
|
const completed = completedWorkUnitCountThrough(snapshot, eventIndex);
|
2026-05-12 14:21:57 +02:00
|
|
|
const active = activeWorkUnitCountThrough(snapshot, eventIndex);
|
2026-06-08 15:30:35 +02:00
|
|
|
const percent = total > 0 ? 55 + Math.ceil((completed / total) * 25) : 55;
|
|
|
|
|
const latest = `${event.unitKey} · ${pluralize(event.toolCalls, 'action')}`;
|
2026-05-12 10:25:58 +02:00
|
|
|
return {
|
|
|
|
|
percent,
|
2026-05-14 01:43:06 +02:00
|
|
|
message: `Processing tasks: ${completed}/${total} complete, ${active} active; latest ${latest}`,
|
2026-05-12 14:21:57 +02:00
|
|
|
transient: true,
|
2026-05-12 10:25:58 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-05-11 22:35:07 +02:00
|
|
|
case 'work_unit_finished': {
|
2026-05-12 10:25:58 +02:00
|
|
|
const total = plannedWorkUnitCountThrough(snapshot, eventIndex);
|
|
|
|
|
const completed = completedWorkUnitCountThrough(snapshot, eventIndex);
|
2026-05-11 22:35:07 +02:00
|
|
|
const percent = total > 0 ? 55 + Math.round((completed / total) * 25) : 80;
|
|
|
|
|
return {
|
|
|
|
|
percent,
|
2026-05-14 01:43:06 +02:00
|
|
|
message: `Processed ${completed}/${total} tasks`,
|
2026-05-11 22:35:07 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
case 'reconciliation_finished':
|
|
|
|
|
return {
|
|
|
|
|
percent: 85,
|
|
|
|
|
message: `Reconciled results with ${pluralize(event.conflictCount, 'conflict')} and ${pluralize(
|
|
|
|
|
event.fallbackCount,
|
|
|
|
|
'fallback',
|
|
|
|
|
)}`,
|
|
|
|
|
};
|
|
|
|
|
case 'saved':
|
|
|
|
|
return {
|
|
|
|
|
percent: 90,
|
|
|
|
|
message: `Saved memory updates (${event.wikiCount} wiki, ${event.slCount} SL)`,
|
|
|
|
|
};
|
|
|
|
|
case 'provenance_recorded':
|
|
|
|
|
return { percent: 95, message: `Recorded ${pluralize(event.rowCount, 'provenance row')}` };
|
|
|
|
|
case 'report_created':
|
|
|
|
|
return { percent: 98, message: `Created ingest report ${event.reportPath ?? event.runId}` };
|
|
|
|
|
case 'scope_detected':
|
|
|
|
|
case 'candidate_action':
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldWritePlainIngestProgress(
|
|
|
|
|
outputMode: KtxIngestOutputMode,
|
|
|
|
|
io: KtxIngestIo,
|
|
|
|
|
env: NodeJS.ProcessEnv,
|
|
|
|
|
): boolean {
|
|
|
|
|
return outputMode === 'plain' && io.stdout.isTTY === true && env.CI !== 'true';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 17:01:48 +02:00
|
|
|
function createPlainIngestProgressObserver(
|
2026-05-11 22:35:07 +02:00
|
|
|
args: Extract<KtxIngestArgs, { command: 'run' }>,
|
2026-05-13 17:01:48 +02:00
|
|
|
onProgress: (update: KtxIngestProgressUpdate) => void,
|
|
|
|
|
): { start(): void; update(snapshot: MemoryFlowReplayInput): void } {
|
2026-05-11 22:35:07 +02:00
|
|
|
let printedEvents = 0;
|
|
|
|
|
let lastPercent = 0;
|
|
|
|
|
let printedCompletion = false;
|
2026-05-12 14:21:57 +02:00
|
|
|
|
|
|
|
|
const write = (percent: number, message: string, options?: { transient?: boolean }) => {
|
2026-05-11 22:35:07 +02:00
|
|
|
const nextPercent = Math.max(lastPercent, Math.max(0, Math.min(100, percent)));
|
|
|
|
|
lastPercent = nextPercent;
|
2026-05-13 17:01:48 +02:00
|
|
|
onProgress({
|
|
|
|
|
percent: nextPercent,
|
|
|
|
|
message,
|
|
|
|
|
...(options?.transient !== undefined ? { transient: options.transient } : {}),
|
|
|
|
|
});
|
2026-05-11 22:35:07 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
start() {
|
|
|
|
|
write(5, `Fetching source files for ${args.connectionId}/${args.adapter}`);
|
|
|
|
|
},
|
|
|
|
|
update(snapshot) {
|
|
|
|
|
while (printedEvents < snapshot.events.length) {
|
2026-05-12 10:25:58 +02:00
|
|
|
const eventIndex = printedEvents;
|
2026-05-11 22:35:07 +02:00
|
|
|
const event = snapshot.events[printedEvents++];
|
|
|
|
|
if (!event) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-12 10:25:58 +02:00
|
|
|
const progress = plainIngestEventProgress(event, snapshot, eventIndex);
|
2026-05-11 22:35:07 +02:00
|
|
|
if (progress) {
|
2026-05-12 14:21:57 +02:00
|
|
|
write(progress.percent, progress.message, progress.transient === true ? { transient: true } : undefined);
|
2026-05-11 22:35:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!printedCompletion && snapshot.status !== 'running') {
|
|
|
|
|
printedCompletion = true;
|
|
|
|
|
write(100, snapshot.status === 'done' ? 'Ingest completed' : 'Ingest failed');
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-13 17:01:48 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createPlainIngestProgressRenderer(
|
|
|
|
|
args: Extract<KtxIngestArgs, { command: 'run' }>,
|
|
|
|
|
io: KtxIngestIo,
|
|
|
|
|
): { start(): void; update(snapshot: MemoryFlowReplayInput): void; flush(): void } {
|
|
|
|
|
let hasPendingTransient = false;
|
|
|
|
|
|
|
|
|
|
const flush = () => {
|
|
|
|
|
if (!hasPendingTransient) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
io.stderr.write('\n');
|
|
|
|
|
hasPendingTransient = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const observer = createPlainIngestProgressObserver(args, (update) => {
|
|
|
|
|
const line = `[${update.percent}%] ${update.message}`;
|
|
|
|
|
if (update.transient === true) {
|
|
|
|
|
io.stderr.write(`\r${line}\u001b[K`);
|
|
|
|
|
hasPendingTransient = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
flush();
|
|
|
|
|
io.stderr.write(`${line}\n`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
start() {
|
|
|
|
|
observer.start();
|
|
|
|
|
},
|
|
|
|
|
update(snapshot) {
|
|
|
|
|
observer.update(snapshot);
|
|
|
|
|
},
|
2026-05-12 14:21:57 +02:00
|
|
|
flush,
|
2026-05-11 22:35:07 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
function writeReportJson(report: IngestReportSnapshot, io: KtxIngestIo): void {
|
2026-05-10 23:12:26 +02:00
|
|
|
io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertReportMatchesReplayId(report: IngestReportSnapshot, requestedId: string, reportFile: string): void {
|
|
|
|
|
const validIds = [report.id, report.runId, report.jobId];
|
|
|
|
|
if (!validIds.includes(requestedId)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Report file ${reportFile} does not match ingest replay id "${requestedId}"; expected one of ${validIds.join(
|
|
|
|
|
', ',
|
|
|
|
|
)}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readStoredIngestReport(
|
fix(cli): resolve managed-embeddings daemon URL at project boundary (#184)
A clean `ktx setup` was failing verification because the managed
local-embeddings daemon URL was passed library-side through
`process.env[KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL]`, and the setup
flow never wrote that variable. With no resolved URL the embedding
provider was null, the deep scan emitted
`scan_enrichment_backend_not_configured`, descriptions + embeddings
stayed `skipped`, and the agent-readiness check exited 1.
Replace the env-var indirection with CLI-side substitution at the
project-load boundary. New `loadKtxCliProject` wraps `loadKtxProject`,
ensures the managed daemon when `managed:local-embeddings` is present in
`config.ingest.embeddings` or `config.scan.enrichment.embeddings`, and
substitutes the resolved baseUrl into the in-memory config. Runtime
entry points (scan, ingest, public-ingest, admin-reindex) use the new
loader; setup-time persistence paths keep raw `loadKtxProject` so the
on-disk `ktx.yaml` keeps the portable sentinel.
Cleanup follows from the new design: drop
`MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV`, remove the env-var lookup
branch in `resolveSentenceTransformersBaseUrl`, drop the `env` field
from `ManagedLocalEmbeddingsDaemon`, and collapse the manual
daemon-ensure dance in `admin-reindex.ts`.
2026-05-20 14:43:02 +02:00
|
|
|
project: KtxLocalProject,
|
2026-05-10 23:12:26 +02:00
|
|
|
runId: string | undefined,
|
|
|
|
|
): Promise<IngestReportSnapshot | null> {
|
|
|
|
|
return runId ? await getLocalIngestStatus(project, runId) : await getLatestLocalIngestStatus(project);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
function isInteractiveTerminal(io: KtxIngestIo): boolean {
|
2026-05-10 23:12:26 +02:00
|
|
|
return io.stdout.isTTY === true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
function terminalWidth(io: KtxIngestIo): number | undefined {
|
2026-05-10 23:12:26 +02:00
|
|
|
return io.stdout.columns ?? process.stdout.columns;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
function isTuiCapableIo(io: KtxIngestIo): io is KtxIngestIo & KtxMemoryFlowTuiIo {
|
2026-05-10 23:12:26 +02:00
|
|
|
return (
|
|
|
|
|
io.stdin?.isTTY === true &&
|
|
|
|
|
io.stdout.isTTY === true &&
|
|
|
|
|
typeof io.stdin.on === 'function' &&
|
|
|
|
|
typeof io.stdin.setRawMode === 'function' &&
|
|
|
|
|
typeof io.stdout.write === 'function'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EffectiveIngestOutputModeOptions {
|
|
|
|
|
requireInput?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function effectiveIngestOutputMode(
|
2026-05-10 23:51:24 +02:00
|
|
|
outputMode: KtxIngestOutputMode,
|
|
|
|
|
io: KtxIngestIo,
|
2026-05-10 23:12:26 +02:00
|
|
|
env: NodeJS.ProcessEnv,
|
|
|
|
|
options: EffectiveIngestOutputModeOptions = {},
|
2026-05-10 23:51:24 +02:00
|
|
|
): KtxIngestOutputMode {
|
2026-05-10 23:12:26 +02:00
|
|
|
if (outputMode !== 'viz') {
|
|
|
|
|
return outputMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fallback = resolveVizFallback(io, env, { requireInput: options.requireInput ?? false });
|
|
|
|
|
if (!fallback.shouldDegrade) {
|
|
|
|
|
return outputMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
warnVizFallbackOnce(io, fallback);
|
|
|
|
|
return 'plain';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
function writeMemoryFlowInput(input: MemoryFlowReplayInput, io: KtxIngestIo, options: { clear?: boolean } = {}): void {
|
2026-05-10 23:12:26 +02:00
|
|
|
if (options.clear) {
|
|
|
|
|
io.stdout.write('\u001b[2J\u001b[H');
|
|
|
|
|
}
|
|
|
|
|
const view = buildMemoryFlowViewModel(input);
|
|
|
|
|
io.stdout.write(renderMemoryFlowReplay(view, { terminalWidth: terminalWidth(io) }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initialRunMemoryFlowInput(
|
2026-05-10 23:51:24 +02:00
|
|
|
args: Extract<KtxIngestArgs, { command: 'run' }>,
|
2026-05-10 23:12:26 +02:00
|
|
|
runId: string,
|
|
|
|
|
): MemoryFlowReplayInput {
|
|
|
|
|
return {
|
|
|
|
|
runId,
|
|
|
|
|
connectionId: args.connectionId,
|
|
|
|
|
adapter: args.adapter,
|
|
|
|
|
status: 'running',
|
|
|
|
|
sourceDir: args.sourceDir ?? null,
|
|
|
|
|
syncId: 'pending',
|
|
|
|
|
errors: [],
|
|
|
|
|
events: [],
|
|
|
|
|
plannedWorkUnits: [],
|
|
|
|
|
details: { actions: [], provenance: [], transcripts: [] },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 16:56:58 -04:00
|
|
|
function finalRunMemoryFlowInput(snapshot: MemoryFlowReplayInput, report: IngestReportSnapshot): MemoryFlowReplayInput {
|
2026-05-30 00:42:59 +02:00
|
|
|
const status = ingestReportOutcome(report) === 'error' ? 'error' : 'done';
|
2026-05-12 16:56:58 -04:00
|
|
|
return {
|
|
|
|
|
...snapshot,
|
|
|
|
|
runId: report.runId,
|
|
|
|
|
connectionId: report.connectionId,
|
|
|
|
|
adapter: report.sourceKey,
|
|
|
|
|
status,
|
|
|
|
|
syncId: report.body.syncId,
|
|
|
|
|
reportId: report.id,
|
|
|
|
|
reportPath: report.id,
|
|
|
|
|
errors: status === 'error' ? report.body.failedWorkUnits : snapshot.errors,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 15:50:34 +02:00
|
|
|
function managedDaemonOptionsForIngestRun(
|
|
|
|
|
args: Extract<KtxIngestArgs, { command: 'run' }>,
|
|
|
|
|
io: KtxIngestIo,
|
|
|
|
|
) {
|
|
|
|
|
if (args.databaseIntrospectionUrl || !args.cliVersion || !args.runtimeInstallPolicy) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
cliVersion: args.cliVersion,
|
2026-05-14 14:35:55 +02:00
|
|
|
projectDir: args.projectDir,
|
2026-05-11 15:50:34 +02:00
|
|
|
installPolicy: args.runtimeInstallPolicy,
|
|
|
|
|
io,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:12:26 +02:00
|
|
|
async function writeReportRecord(
|
|
|
|
|
report: IngestReportSnapshot,
|
2026-05-10 23:51:24 +02:00
|
|
|
outputMode: KtxIngestOutputMode,
|
|
|
|
|
io: KtxIngestIo,
|
2026-05-10 23:12:26 +02:00
|
|
|
options: {
|
|
|
|
|
interactive?: boolean;
|
|
|
|
|
renderStoredMemoryFlow?: typeof renderMemoryFlowTui;
|
|
|
|
|
env?: NodeJS.ProcessEnv;
|
|
|
|
|
} = {},
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (outputMode === 'json') {
|
|
|
|
|
writeReportJson(report, io);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolvedOutputMode = effectiveIngestOutputMode(outputMode, io, options.env ?? process.env, {
|
|
|
|
|
requireInput: options.interactive === true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (resolvedOutputMode === 'viz') {
|
|
|
|
|
const input = ingestReportToMemoryFlowReplay(report, { provenanceRowCount: report.body.provenanceRows.length });
|
|
|
|
|
if (options.interactive === true) {
|
|
|
|
|
if (io.stdin?.isTTY === true) {
|
|
|
|
|
const renderStoredMemoryFlow = options.renderStoredMemoryFlow ?? renderMemoryFlowTui;
|
|
|
|
|
if (isTuiCapableIo(io) && (await renderStoredMemoryFlow(input, io))) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await renderMemoryFlowInteractively(input, io);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeMemoryFlowInput(input, io);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeMemoryFlowInput(input, io);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeReportStatus(report, io);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
export async function runKtxIngest(
|
|
|
|
|
args: KtxIngestArgs,
|
|
|
|
|
io: KtxIngestIo = process,
|
|
|
|
|
deps: KtxIngestDeps = {},
|
2026-05-10 23:12:26 +02:00
|
|
|
): Promise<number> {
|
|
|
|
|
try {
|
2026-05-21 02:21:22 +02:00
|
|
|
const project = await loadKtxProject({ projectDir: args.projectDir });
|
2026-05-10 23:12:26 +02:00
|
|
|
const env = deps.env ?? process.env;
|
|
|
|
|
if (args.command === 'run') {
|
chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm
* refactor(workspace): rewrite @ktx/llm imports to relative paths
* refactor(workspace): fold internal packages into cli
* chore(workspace): gate dead-code with knip production mode
Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.
* refactor(cli): delete internal barrel index.ts files
The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).
This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
(the published package entry).
- Rewrites ~270 source/test files to import each name directly from
the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
`create-warehouse-verification-tools.ts` (the function it defined
locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
live-database/extracted-schema, live-database/structural-sync,
relationship-* feedback/review chain) plus their tests and a
cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
(notion-client, connector barrels in scan/local-scan-connectors
tests) to mock the source files instead.
- Points the maintainer benchmark script
(`scripts/relationship-benchmark-report.mjs`) at source files
instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
production entries only for the benchmark code reached via dist by
the maintainer script.
Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.
`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.
* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly
Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.
Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.
* docs: align "agent clients" and "data agents" terminology
Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.
Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.
* refactor(release): single source of truth for package version
Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.
Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.
- update-public-release-version.mjs rewrites both Python pyproject.toml
files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
@semantic-release/git assets so the release commit back to main
carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
replaced with "?? getKtxCliPackageInfo().version", and
createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
always reflects the most recent release; no sentinel pin to
maintain.
Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.
* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime
Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.
* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal
Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.
* fix(cli): use real package metadata in print-command-tree
The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.
* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts
Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.
Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
|
|
|
const resolveEmbeddingProvider = deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider;
|
|
|
|
|
const resolution = await resolveEmbeddingProvider(project, {
|
2026-05-21 02:21:22 +02:00
|
|
|
mode: 'ensure',
|
|
|
|
|
installPolicy: args.runtimeInstallPolicy ?? 'never',
|
chore(workspace): gate dead-code with knip production mode (#196)
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm
* refactor(workspace): rewrite @ktx/llm imports to relative paths
* refactor(workspace): fold internal packages into cli
* chore(workspace): gate dead-code with knip production mode
Turn on production-mode knip plus an autofix run in pre-commit and the
`pnpm dead-code` script, document the `/** @internal */` convention for
test-only exports in AGENTS.md, annotate test-only exports across the
CLI with that JSDoc, and drop dead exports/wrappers the new gate
surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`,
`createLocalScanEnrichmentProvidersFromConfig`,
`PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports).
Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit
production entries so cross-package barrel leaks are caught.
* refactor(cli): delete internal barrel index.ts files
The 34 `index.ts` re-export barrels inside `packages/cli/src/` were
holdovers from the pre-fold multi-workspace structure. Post-fold-in they
served no production purpose: external consumers go through the single
package main entry, and in-repo callers mostly imported through them
only because the path was short. Internally, knip flagged most barrel
re-exports as production-dead (only reached via tests).
This change:
- Deletes every internal barrel except `packages/cli/src/index.ts`
(the published package entry).
- Rewrites ~270 source/test files to import each name directly from
the file that defines it.
- Moves `tools/warehouse-verification/index.ts` to
`create-warehouse-verification-tools.ts` (the function it defined
locally) and updates its single consumer.
- Renames `search/backend-conformance.ts` → `.test-utils.ts` to match
the existing test-helper file convention.
- Deletes 13 dead test-only chains (dbt-descriptions/*,
live-database/extracted-schema, live-database/structural-sync,
relationship-* feedback/review chain) plus their tests and a
cascading orphan integration test.
- Updates test mocks that pointed at deleted barrel paths
(notion-client, connector barrels in scan/local-scan-connectors
tests) to mock the source files instead.
- Points the maintainer benchmark script
(`scripts/relationship-benchmark-report.mjs`) at source files
instead of `dist/context/scan/index.js`.
- Drops the barrel `!` entries from `knip.json`; adds explicit
production entries only for the benchmark code reached via dist by
the maintainer script.
Net: 413 files changed, ~1.2k insertions, ~9.4k deletions.
`pnpm run dead-code` (Biome + knip default + knip production) and
`pnpm run type-check` are clean; 2277 tests pass.
* refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly
Promote the CLI workspace package to the public name `@kaelio/ktx` and
drop the separate `scripts/build-public-npm-package.mjs` wrapper. The
CLI package is now publishable in place (`publishConfig.access: public`,
`provenance: true`), so artifact packing uses `pnpm pack` against
`packages/cli/` instead of assembling a parallel package tree.
Updates all workspace filter invocations, docs, tests, and release
readiness checks to reference the new package name, and folds the
tarball-name helper into `scripts/public-npm-release-metadata.mjs`.
* docs: align "agent clients" and "data agents" terminology
Replace "client agents" with "agent clients" and "database agents" with
"data agents" across AGENTS.md, README.md, the docs-site copy, and the
matching setup-agents test description, matching the canonical
vocabulary in docs/terminology.md.
Also moves packages/cli/tsconfig.json's tsBuildInfoFile from
node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive
node_modules reinstalls.
* refactor(release): single source of truth for package version
Make packages/cli/package.json the single source of truth for the
@kaelio/ktx version. publicNpmPackageVersion() now reads it directly,
so artifact filenames, release-readiness checks, and the Python wheel
version all derive from one field. The duplicate
release-policy.json.publicNpmPackageVersion is removed.
Previously the two fields could drift: tarballs were named
kaelio-ktx-0.4.1.tgz while internally containing
@kaelio/ktx@0.0.0-private.
- update-public-release-version.mjs rewrites both Python pyproject.toml
files (ktx-daemon, ktx-sl) alongside the npm package.jsons,
normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2).
- semantic-release-config.cjs adds the two pyproject.toml files to
@semantic-release/git assets so the release commit back to main
carries every version source in lockstep.
- The six "?? '0.0.0-private'" fallback literals across the CLI are
replaced with "?? getKtxCliPackageInfo().version", and
createDefaultKtxMcpServer makes its version arg required.
- docs/release.md describes the actual commit-back model: the dev tree
always reflects the most recent release; no sentinel pin to
maintain.
Verified: pnpm run artifacts:build now produces
kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with
@kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and
2287 vitests + 173 script tests pass.
* refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime
Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and
scan command entrypoints so tests can stub them, and teach
resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime
feature when ktx.yaml selects sentence-transformers.
* chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal
Both symbols are consumed only by status-project.test.ts. Annotating with
/** @internal */ keeps knip's production-mode check clean without changing
runtime behavior.
* fix(cli): use real package metadata in print-command-tree
The stubbed package name embedded a forbidden product identifier that
tripped the boundary check in CI. Read the metadata from package.json
instead — keeps the rendered tree unchanged and removes a duplicate
source of truth.
* feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts
Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer
source counts, computed with `SUM(embedding_json IS NOT NULL)` over
`knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to
"Wiki" (canonical per `docs/terminology.md`) and rename the matching
`localStats.knowledgePages` field to `localStats.wikiPages`.
Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those
duplicated the per-surface rows above. Disk now reports only actual byte
usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` /
`semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry`
helpers, and the `filter` arg on `summarizeDir` are removed.
2026-05-21 15:28:58 +02:00
|
|
|
cliVersion: args.cliVersion ?? getKtxCliPackageInfo().version,
|
|
|
|
|
io: deps.runtimeIo ?? io,
|
2026-05-21 02:21:22 +02:00
|
|
|
});
|
|
|
|
|
const embeddingProvider =
|
|
|
|
|
resolution.kind === 'disabled' || resolution.kind === 'managed-unavailable' ? null : resolution.provider;
|
2026-05-14 01:43:06 +02:00
|
|
|
const ingestProject =
|
|
|
|
|
args.allowImplicitAdapter && !project.config.ingest.adapters.includes(args.adapter)
|
|
|
|
|
? {
|
|
|
|
|
...project,
|
|
|
|
|
config: {
|
|
|
|
|
...project.config,
|
|
|
|
|
ingest: {
|
|
|
|
|
...project.config.ingest,
|
|
|
|
|
adapters: [...project.config.ingest.adapters, args.adapter],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: project;
|
2026-05-13 12:00:08 +02:00
|
|
|
const createAdapters =
|
|
|
|
|
deps.createAdapters ??
|
|
|
|
|
(deps.runLocalIngest || deps.runLocalMetabaseIngest ? () => [] : createKtxCliLocalIngestAdapters);
|
2026-05-10 23:12:26 +02:00
|
|
|
const executeLocalIngest = deps.runLocalIngest ?? runLocalIngest;
|
|
|
|
|
const localIngestOptions = deps.localIngestOptions ?? {};
|
2026-05-16 11:39:43 +02:00
|
|
|
const managedDaemon = managedDaemonOptionsForIngestRun(args, deps.runtimeIo ?? io);
|
2026-05-12 11:26:34 +02:00
|
|
|
const operationalLogger = createCliOperationalLogger(io, args.outputMode);
|
fix: read semantic sources safely (#284)
* fix: read semantic sources safely
* test: retarget reindex per-scope error case to a broken manifest
Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.
* fix(sl): decouple semantic-layer file names from warehouse naming rules
The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.
- New `source-files.ts`: one total filename derivation (safe lowercase
names verbatim; otherwise slug + sha256-hash suffix, immune to
case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
`assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
name (human renames survive); new sources get a derived filename; a
derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
caller to launder IO errors; `deleteSource` distinguishes manifest-backed
sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
now a recommendation for new sources) and rejects sourceName/source.name
mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
derivation use resolved paths / in-file names instead of interpolating
`<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
helpers onto the shared module; dropped dead service methods.
* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator
- Key broken/renamed semantic-layer files by their recoverable in-file
name (slSourceNameForFile) so mid-edit sources stay reachable under
their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
recover deleted files' declared names from the pre-change commit
instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
validateReadOnly in the connection's dialect, sharing one
driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
reject smuggled second statements, and strip trailing
semicolons/comments before row-limit wrapping
2026-06-10 14:06:13 +02:00
|
|
|
const baseAdapterOptions = {
|
2026-05-10 23:12:26 +02:00
|
|
|
...(localIngestOptions.pullConfigOptions ?? {}),
|
|
|
|
|
...(args.databaseIntrospectionUrl ? { databaseIntrospectionUrl: args.databaseIntrospectionUrl } : {}),
|
2026-05-11 15:50:34 +02:00
|
|
|
...(managedDaemon ? { managedDaemon } : {}),
|
2026-05-10 23:12:26 +02:00
|
|
|
...(args.adapter === 'historic-sql' ? { historicSqlConnectionId: args.connectionId } : {}),
|
2026-05-14 01:43:06 +02:00
|
|
|
...(args.historicSqlPullConfigOverride
|
|
|
|
|
? { historicSqlPullConfigOverride: args.historicSqlPullConfigOverride }
|
|
|
|
|
: {}),
|
2026-05-12 11:26:34 +02:00
|
|
|
logger: operationalLogger,
|
2026-05-10 23:12:26 +02:00
|
|
|
};
|
fix: read semantic sources safely (#284)
* fix: read semantic sources safely
* test: retarget reindex per-scope error case to a broken manifest
Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.
* fix(sl): decouple semantic-layer file names from warehouse naming rules
The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.
- New `source-files.ts`: one total filename derivation (safe lowercase
names verbatim; otherwise slug + sha256-hash suffix, immune to
case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
`assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
name (human renames survive); new sources get a derived filename; a
derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
caller to launder IO errors; `deleteSource` distinguishes manifest-backed
sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
now a recommendation for new sources) and rejects sourceName/source.name
mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
derivation use resolved paths / in-file names instead of interpolating
`<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
helpers onto the shared module; dropped dead service methods.
* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator
- Key broken/renamed semantic-layer files by their recoverable in-file
name (slSourceNameForFile) so mid-edit sources stay reachable under
their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
recover deleted files' declared names from the pre-change commit
instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
validateReadOnly in the connection's dialect, sharing one
driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
reject smuggled second statements, and strip trailing
semicolons/comments before row-limit wrapping
2026-06-10 14:06:13 +02:00
|
|
|
// One parser-backed SQL analysis port per run: the historic-sql adapter and
|
|
|
|
|
// the ingest sql_execution tool share the same daemon-backed validator.
|
|
|
|
|
const sqlAnalysis = localIngestOptions.sqlAnalysis ?? resolveKtxCliSqlAnalysis(baseAdapterOptions);
|
|
|
|
|
const adapterOptions = { ...baseAdapterOptions, sqlAnalysis };
|
2026-05-13 13:43:23 +02:00
|
|
|
const queryExecutor =
|
|
|
|
|
localIngestOptions.queryExecutor ??
|
2026-05-14 01:43:06 +02:00
|
|
|
(deps.createQueryExecutor ?? createKtxCliIngestQueryExecutor)(ingestProject);
|
2026-05-10 23:12:26 +02:00
|
|
|
if (args.adapter === 'metabase' && args.sourceDir) {
|
2026-05-25 11:09:33 -04:00
|
|
|
throw new Error('source-dir uploads are not supported for the Metabase fanout adapter');
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
|
|
|
|
if (args.adapter === 'metabase') {
|
|
|
|
|
const executeMetabaseFanout = deps.runLocalMetabaseIngest ?? runLocalMetabaseIngest;
|
2026-05-18 13:38:06 +02:00
|
|
|
const runOutputMode = effectiveIngestOutputMode(args.outputMode, io, env, {
|
|
|
|
|
requireInput: (args.inputMode ?? 'auto') === 'auto',
|
|
|
|
|
});
|
|
|
|
|
const plainProgress = shouldWritePlainIngestProgress(runOutputMode, io, env)
|
|
|
|
|
? createPlainIngestProgressRenderer(args, io)
|
|
|
|
|
: null;
|
|
|
|
|
const structuredProgress = deps.progress
|
|
|
|
|
? createPlainIngestProgressObserver(args, deps.progress)
|
|
|
|
|
: null;
|
|
|
|
|
const initialMemoryFlow =
|
|
|
|
|
plainProgress || structuredProgress ? initialRunMemoryFlowInput(args, 'pending') : undefined;
|
|
|
|
|
const memoryFlow = initialMemoryFlow
|
|
|
|
|
? createMemoryFlowLiveBuffer(initialMemoryFlow, {
|
|
|
|
|
onChange: (snapshot) => {
|
|
|
|
|
plainProgress?.update(snapshot);
|
|
|
|
|
structuredProgress?.update(snapshot);
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
: undefined;
|
2026-05-10 23:12:26 +02:00
|
|
|
const progress =
|
2026-05-13 17:01:48 +02:00
|
|
|
args.outputMode === 'json' && !deps.progress
|
|
|
|
|
? undefined
|
|
|
|
|
: createMetabaseFanoutProgress(
|
|
|
|
|
args.connectionId,
|
|
|
|
|
args.outputMode === 'json'
|
|
|
|
|
? { ...io, stderr: { write: () => undefined } }
|
|
|
|
|
: io,
|
|
|
|
|
deps.progress,
|
|
|
|
|
);
|
2026-05-18 13:38:06 +02:00
|
|
|
plainProgress?.start();
|
|
|
|
|
structuredProgress?.start();
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
const cliAbort = deps.abortSignal ? null : createCliAbortSignal();
|
|
|
|
|
const abortSignal = deps.abortSignal ?? cliAbort?.signal;
|
2026-05-18 13:38:06 +02:00
|
|
|
let result: LocalMetabaseFanoutResult;
|
|
|
|
|
try {
|
|
|
|
|
result = await executeMetabaseFanout({
|
|
|
|
|
project: ingestProject,
|
|
|
|
|
adapters: createAdapters(ingestProject, adapterOptions),
|
|
|
|
|
metabaseConnectionId: args.connectionId,
|
|
|
|
|
...localIngestOptions,
|
|
|
|
|
queryExecutor,
|
fix: read semantic sources safely (#284)
* fix: read semantic sources safely
* test: retarget reindex per-scope error case to a broken manifest
Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.
* fix(sl): decouple semantic-layer file names from warehouse naming rules
The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.
- New `source-files.ts`: one total filename derivation (safe lowercase
names verbatim; otherwise slug + sha256-hash suffix, immune to
case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
`assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
name (human renames survive); new sources get a derived filename; a
derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
caller to launder IO errors; `deleteSource` distinguishes manifest-backed
sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
now a recommendation for new sources) and rejects sourceName/source.name
mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
derivation use resolved paths / in-file names instead of interpolating
`<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
helpers onto the shared module; dropped dead service methods.
* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator
- Key broken/renamed semantic-layer files by their recoverable in-file
name (slSourceNameForFile) so mid-edit sources stay reachable under
their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
recover deleted files' declared names from the pre-change commit
instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
validateReadOnly in the connection's dialect, sharing one
driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
reject smuggled second statements, and strip trailing
semicolons/comments before row-limit wrapping
2026-06-10 14:06:13 +02:00
|
|
|
sqlAnalysis,
|
2026-05-18 13:38:06 +02:00
|
|
|
trigger: 'manual_resync',
|
|
|
|
|
jobIdFactory: deps.jobIdFactory,
|
2026-05-21 02:21:22 +02:00
|
|
|
embeddingProvider,
|
2026-05-18 13:38:06 +02:00
|
|
|
...(memoryFlow ? { memoryFlow } : {}),
|
|
|
|
|
...(progress ? { progress } : {}),
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
...(abortSignal ? { abortSignal } : {}),
|
2026-05-18 13:38:06 +02:00
|
|
|
});
|
|
|
|
|
plainProgress?.flush();
|
|
|
|
|
if (args.outputMode === 'json') {
|
|
|
|
|
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
|
|
|
} else {
|
|
|
|
|
writeMetabaseFanoutStatus(result, io);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
plainProgress?.flush();
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
cliAbort?.dispose();
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
2026-05-30 00:42:59 +02:00
|
|
|
return result.status === 'all_failed' ? 1 : 0;
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const jobId = deps.jobIdFactory?.();
|
|
|
|
|
let liveTui: MemoryFlowTuiLiveSession | null = null;
|
|
|
|
|
const runOutputMode = effectiveIngestOutputMode(args.outputMode, io, env, {
|
|
|
|
|
requireInput: (args.inputMode ?? 'auto') === 'auto',
|
|
|
|
|
});
|
|
|
|
|
const shouldUseLiveViz =
|
|
|
|
|
runOutputMode === 'viz' && (args.inputMode ?? 'auto') === 'auto' && isInteractiveTerminal(io);
|
2026-05-11 22:35:07 +02:00
|
|
|
const plainProgress = shouldWritePlainIngestProgress(runOutputMode, io, env)
|
|
|
|
|
? createPlainIngestProgressRenderer(args, io)
|
|
|
|
|
: null;
|
2026-05-13 17:01:48 +02:00
|
|
|
const structuredProgress = deps.progress
|
|
|
|
|
? createPlainIngestProgressObserver(args, deps.progress)
|
|
|
|
|
: null;
|
2026-05-11 22:35:07 +02:00
|
|
|
const initialMemoryFlow =
|
2026-05-13 17:01:48 +02:00
|
|
|
shouldUseLiveViz || plainProgress || structuredProgress
|
|
|
|
|
? initialRunMemoryFlowInput(args, jobId ?? 'pending')
|
|
|
|
|
: undefined;
|
2026-05-10 23:12:26 +02:00
|
|
|
let latestMemoryFlowSnapshot: MemoryFlowReplayInput | null = initialMemoryFlow ?? null;
|
|
|
|
|
|
2026-05-11 22:35:07 +02:00
|
|
|
if (shouldUseLiveViz && initialMemoryFlow && isTuiCapableIo(io)) {
|
2026-05-10 23:12:26 +02:00
|
|
|
const startLiveMemoryFlow = deps.startLiveMemoryFlow ?? startLiveMemoryFlowTui;
|
|
|
|
|
liveTui = await startLiveMemoryFlow(initialMemoryFlow, io);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const memoryFlow = initialMemoryFlow
|
|
|
|
|
? createMemoryFlowLiveBuffer(initialMemoryFlow, {
|
|
|
|
|
onChange: (snapshot) => {
|
|
|
|
|
latestMemoryFlowSnapshot = snapshot;
|
|
|
|
|
if (liveTui && !liveTui.isClosed()) {
|
|
|
|
|
liveTui.update(snapshot);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-11 22:35:07 +02:00
|
|
|
if (shouldUseLiveViz && !liveTui) {
|
2026-05-10 23:12:26 +02:00
|
|
|
writeMemoryFlowInput(snapshot, io, { clear: true });
|
2026-05-11 22:35:07 +02:00
|
|
|
return;
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
2026-05-11 22:35:07 +02:00
|
|
|
plainProgress?.update(snapshot);
|
2026-05-13 17:01:48 +02:00
|
|
|
structuredProgress?.update(snapshot);
|
2026-05-10 23:12:26 +02:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2026-05-11 22:35:07 +02:00
|
|
|
plainProgress?.start();
|
2026-05-13 17:01:48 +02:00
|
|
|
structuredProgress?.start();
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
const cliAbort = deps.abortSignal ? null : createCliAbortSignal();
|
|
|
|
|
const abortSignal = deps.abortSignal ?? cliAbort?.signal;
|
2026-05-11 22:35:07 +02:00
|
|
|
|
2026-05-10 23:12:26 +02:00
|
|
|
try {
|
|
|
|
|
const result = await executeLocalIngest({
|
2026-05-14 01:43:06 +02:00
|
|
|
project: ingestProject,
|
|
|
|
|
adapters: createAdapters(ingestProject, adapterOptions),
|
2026-05-10 23:12:26 +02:00
|
|
|
adapter: args.adapter,
|
|
|
|
|
connectionId: args.connectionId,
|
|
|
|
|
sourceDir: args.sourceDir,
|
|
|
|
|
trigger: 'manual_resync',
|
|
|
|
|
jobId,
|
|
|
|
|
...localIngestOptions,
|
2026-05-13 13:43:23 +02:00
|
|
|
queryExecutor,
|
fix: read semantic sources safely (#284)
* fix: read semantic sources safely
* test: retarget reindex per-scope error case to a broken manifest
Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.
* fix(sl): decouple semantic-layer file names from warehouse naming rules
The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.
- New `source-files.ts`: one total filename derivation (safe lowercase
names verbatim; otherwise slug + sha256-hash suffix, immune to
case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
`assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
name (human renames survive); new sources get a derived filename; a
derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
caller to launder IO errors; `deleteSource` distinguishes manifest-backed
sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
now a recommendation for new sources) and rejects sourceName/source.name
mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
derivation use resolved paths / in-file names instead of interpolating
`<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
helpers onto the shared module; dropped dead service methods.
* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator
- Key broken/renamed semantic-layer files by their recoverable in-file
name (slSourceNameForFile) so mid-edit sources stay reachable under
their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
recover deleted files' declared names from the pre-change commit
instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
validateReadOnly in the connection's dialect, sharing one
driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
reject smuggled second statements, and strip trailing
semicolons/comments before row-limit wrapping
2026-06-10 14:06:13 +02:00
|
|
|
sqlAnalysis,
|
2026-05-11 15:50:34 +02:00
|
|
|
pullConfigOptions: adapterOptions,
|
2026-05-21 02:21:22 +02:00
|
|
|
embeddingProvider,
|
2026-05-10 23:12:26 +02:00
|
|
|
...(args.debugLlmRequestFile ? { llmDebugRequestFile: args.debugLlmRequestFile } : {}),
|
|
|
|
|
...(memoryFlow ? { memoryFlow } : {}),
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
...(abortSignal ? { abortSignal } : {}),
|
2026-05-10 23:12:26 +02:00
|
|
|
});
|
2026-05-11 22:35:07 +02:00
|
|
|
if (shouldUseLiveViz && memoryFlow) {
|
2026-05-12 16:56:58 -04:00
|
|
|
latestMemoryFlowSnapshot = finalRunMemoryFlowInput(memoryFlow.snapshot(), result.report);
|
2026-05-10 23:12:26 +02:00
|
|
|
liveTui?.close();
|
|
|
|
|
liveTui = null;
|
|
|
|
|
io.stdout.write(formatMemoryFlowFinalSummary(latestMemoryFlowSnapshot));
|
2026-05-30 00:42:59 +02:00
|
|
|
return ingestReportOutcome(result.report) === 'error' ? 1 : 0;
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
2026-05-12 14:21:57 +02:00
|
|
|
plainProgress?.flush();
|
2026-05-10 23:12:26 +02:00
|
|
|
await writeReportRecord(result.report, runOutputMode, io, {
|
|
|
|
|
interactive: (args.inputMode ?? 'auto') === 'auto',
|
|
|
|
|
renderStoredMemoryFlow: deps.renderStoredMemoryFlow,
|
|
|
|
|
env,
|
|
|
|
|
});
|
2026-05-30 00:42:59 +02:00
|
|
|
return ingestReportOutcome(result.report) === 'error' ? 1 : 0;
|
2026-05-10 23:12:26 +02:00
|
|
|
} finally {
|
2026-05-12 14:21:57 +02:00
|
|
|
plainProgress?.flush();
|
2026-05-10 23:12:26 +02:00
|
|
|
liveTui?.close();
|
feat(cli): add ingest LLM rate-limit governor with paced retries (#261)
* feat(cli): add ingest rate limit governor
* feat(cli): wire ingest rate-limit config
* feat(cli): report provider rate-limit signals
* feat(cli): show ingest rate-limit waits
* fix(cli): complete rate-limit event coverage
* fix(cli): abort ingest provider calls cleanly
* fix(cli): propagate ingest cancellation
* fix(cli): reject pre-aborted ingest rate-limit waits
* fix(cli): honor Claude rate-limit reset waits
* fix(cli): retry thrown Codex rate-limit failures
* fix(cli): type Claude rate-limit result details
* fix(cli): emit ingest rate-limit countdowns from rejected signals
* fix(cli): report ai sdk rate-limit header utilization
* fix(cli): gate LLM rate-limit retries on the governor budget
The AI SDK and Codex runtimes retried 429 / opaque rate-limit failures up
to 6-7 times with no backoff when constructed without a RateLimitGovernor
(scan, memory, setup) or with pacing disabled, ignoring Retry-After and
worsening the limit. The outer retry loop only cooperates with the
governor's pause, so without active pacing there is no backoff to apply.
Route the retry bound through a single source: RateLimitGovernor
.maxRetryAttempts(), which returns retry.maxAttempts when enabled and 1
(no outer retry) when absent or disabled. All three runtimes (ai-sdk,
codex, claude-code) now use it, so ingest.rateLimit.retry.maxAttempts
genuinely controls attempts and the hard-coded 6 (plus Codex's off-by-one
extra attempt) is gone. Backend-native retry (e.g. the AI SDK's maxRetries)
still handles transient 429s.
Also correct the ktx.yaml docs for maxWaitMs (caps each wait, not the whole
run) and maxAttempts, and sync uv.lock ktx-sl/ktx-daemon to 0.9.0.
2026-06-05 12:10:27 +02:00
|
|
|
cliAbort?.dispose();
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (args.reportFile) {
|
|
|
|
|
const readReportFile = deps.readReportFile ?? readIngestReportSnapshotFile;
|
|
|
|
|
const report = await readReportFile(args.reportFile);
|
|
|
|
|
if (args.runId) {
|
|
|
|
|
assertReportMatchesReplayId(report, args.runId, args.reportFile);
|
|
|
|
|
}
|
|
|
|
|
await writeReportRecord(report, args.outputMode, io, {
|
|
|
|
|
interactive: (args.inputMode ?? 'auto') === 'auto',
|
|
|
|
|
renderStoredMemoryFlow: deps.renderStoredMemoryFlow,
|
|
|
|
|
env,
|
|
|
|
|
});
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await readStoredIngestReport(project, args.runId);
|
|
|
|
|
if (!report) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
args.runId
|
|
|
|
|
? `Local ingest run or report "${args.runId}" was not found`
|
2026-05-14 01:43:06 +02:00
|
|
|
: 'No local ingest reports were found. Run `ktx ingest <connectionId>` first.',
|
2026-05-10 23:12:26 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await writeReportRecord(report, args.outputMode, io, {
|
|
|
|
|
interactive: (args.inputMode ?? 'auto') === 'auto',
|
|
|
|
|
renderStoredMemoryFlow: deps.renderStoredMemoryFlow,
|
|
|
|
|
env,
|
|
|
|
|
});
|
|
|
|
|
return 0;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
}
|