ktx/packages/cli/test/context/ingest/ingest-bundle.runner.test.ts

2437 lines
86 KiB
TypeScript
Raw Normal View History

feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2026-05-10 23:12:26 +02:00
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
test: split cli tests from source tree (#216) * feat(cli): define full warehouse dialect contract * test(cli): keep dialect edge tests focused * fix(cli): stabilize dialect contract foundation * refactor(connectors): own read-only query preparation * refactor(connectors): resolve dialects through registry * refactor(connectors): keep concrete dialect classes internal * chore(workspace): enforce dialect import boundary * refactor(cli): resolve relationship dialect at scan boundary * refactor(cli): use dialect display parsing for entity details * refactor(cli): use dialect display parsing for warehouse catalog * refactor(cli): use dialect SQL in relationship workflows * test(cli): verify solid dialect scan workflow closure * test: split cli tests from source tree * refactor(cli): standardize BigQuery scope listing * feat(sqlite): implement connector scope listing * test(connectors): cover required table listing * feat(cli): add warehouse driver registry * refactor(setup): route scope discovery through driver registry * refactor(cli): route local query execution through driver registry * refactor(historic-sql): route dialect support through driver registry * refactor(cli): test warehouse connections through driver registry * fix(cli): close driver registry type export gaps * Improve setup daemon diagnostics * refactor(setup): centralize rail-prefixed diagnostics + query-history fallback Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput into clack.ts so the setup wizard, managed daemons, and embedding/agent steps share one rail-formatted writer. setup-databases.ts also adds a "disable query history and retry" option when the schema-context build fails and query history is the likely culprit, surfaced via a new failed-query-history-unavailable status. * fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match The setup picker's KtxTableListEntry was a 2-level { schema, name }, so qualifiedTableId always wrote db.name into enabled_tables. When BigQuery, Snowflake, or SQL Server later ran fast ingest, their introspect step filtered the scope set with scopedTableNames(scope, { catalog: projectId|database, db }) — catalog was non-null on the introspect side but null in the scope refs, so every entry was rejected, the live-database adapter staged zero table files, and detect() failed with 'Adapter "live-database" did not recognize fetched source output'. Align the picker boundary with the canonical 3-level KtxTableRef: - Add catalog: string | null to KtxTableListEntry. - BigQuery/Snowflake/SQL Server listTables populate catalog from the resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null. - qualifiedTableId emits catalog.schema.name when catalog is non-null (resolveEnabledTables already accepts the 3-part shape) and schemasFromEnabledTables now goes through parseDottedTableEntry so it recovers the schema correctly from both 2-part and 3-part entries. - Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker reuse. Update listTables expectations in all seven connector tests and the setup / picker test fixtures. Add a picker regression test that covers the catalog-bearing round-trip (save + refine). * fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
import { addTouchedSlSource } from '../../../src/context/tools/touched-sl-sources.js';
import { IngestBundleRunner } from '../../../src/context/ingest/ingest-bundle.runner.js';
import { createMemoryFlowLiveBuffer } from '../../../src/context/ingest/memory-flow/live-buffer.js';
import type { MemoryFlowReplayInput } from '../../../src/context/ingest/memory-flow/types.js';
import type { IngestBundleRunnerDeps } from '../../../src/context/ingest/ports.js';
2026-05-10 23:12:26 +02:00
class TestJobContext {
private currentProgress = 0;
constructor(
public readonly jobId: string,
public readonly userId: string | null | undefined,
public readonly checkCancellation: () => Promise<void>,
private readonly updateProgressFn: (progress: number, message?: string) => Promise<void>,
private readonly parent?: TestJobContext,
private readonly start = 0,
private readonly span = 1,
) {}
async updateProgress(progress: number, message?: string): Promise<void> {
const local = Math.max(0, Math.min(1, progress));
this.currentProgress = local;
if (this.parent) {
await this.parent.updateProgress(Math.max(0, Math.min(1, this.start + this.span * local)), message);
return;
}
await this.updateProgressFn(local, message);
}
startPhase(fraction: number): TestJobContext {
return new TestJobContext(
this.jobId,
this.userId,
this.checkCancellation,
this.updateProgressFn,
this,
this.currentProgress,
Math.max(0, Math.min(1, fraction)),
);
}
}
const deferred = <T>() => {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
};
function bundleReplayInput(): MemoryFlowReplayInput {
return {
runId: 'pending',
connectionId: 'c1',
adapter: 'fake',
status: 'running',
sourceDir: '/tmp/stage/upload-x',
syncId: 'pending',
errors: [],
events: [],
plannedWorkUnits: [],
details: { actions: [], provenance: [], transcripts: [] },
};
}
const makeDeps = () => {
const runsRepo = {
create: vi.fn().mockResolvedValue({ id: 'run-1' }),
findMostRecentCompleted: vi.fn().mockResolvedValue(null),
markFailed: vi.fn(),
markCompleted: vi.fn(),
};
const provenanceRepo = {
insertMany: vi.fn(),
findHashesBySync: vi.fn().mockResolvedValue(new Map()),
findLatestArtifactsForRawPaths: vi.fn().mockResolvedValue(new Map()),
};
const reportsRepo = {
create: vi.fn().mockResolvedValue({ id: 'report-1' }),
findByJobId: vi.fn().mockResolvedValue(null),
markSuperseded: vi.fn().mockResolvedValue(undefined),
};
const canonicalPins = {
listPins: vi.fn().mockResolvedValue([]),
};
const adapter = {
source: 'fake',
skillNames: [] as string[],
reconcileSkillNames: undefined as undefined | string[],
evidenceIndexing: undefined as undefined | 'documents',
triageSupported: undefined as undefined | boolean,
detect: vi.fn().mockResolvedValue(true),
listTargetConnectionIds: undefined as undefined | ((stagedDir: string) => Promise<string[]>),
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
finalize: undefined as any,
2026-05-10 23:12:26 +02:00
chunk: vi.fn().mockResolvedValue({
workUnits: [{ unitKey: 'u1', rawFiles: ['a.yml'], peerFileIndex: [], dependencyPaths: [] }],
}),
};
const registry = { get: vi.fn().mockReturnValue(adapter) };
const diffSetService = {
compute: vi.fn().mockResolvedValue({ added: ['a.yml'], modified: [], deleted: [], unchanged: [] }),
};
const contextEvidenceIndex = {
indexStagedDir: vi.fn().mockResolvedValue({
documentsIndexed: 1,
chunksIndexed: 1,
documentsDeleted: 0,
embeddingFailures: 0,
warnings: [],
}),
publishSync: vi.fn().mockResolvedValue(undefined),
};
const pageTriage = {
triageRun: vi.fn().mockResolvedValue({
enabled: true,
fullRawPaths: new Set(['a.yml']),
warnings: [],
}),
};
const scopedGit = {
revParseHead: vi.fn().mockResolvedValue('h'),
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
commitFiles: vi.fn().mockResolvedValue({ created: true, commitHash: 'h' }),
commitStaged: vi.fn().mockResolvedValue({ created: false, commitHash: 'h' }),
2026-05-10 23:12:26 +02:00
resetHardTo: vi.fn(),
assertWorktreeClean: vi.fn().mockResolvedValue(undefined),
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
writeBinaryNoRenamePatch: vi.fn(async (_base: string, _head: string, patchPath: string) => {
await writeFile(patchPath, '', 'utf-8');
}),
applyPatchFile3WayIndex: vi.fn(),
diffNameStatus: vi.fn().mockResolvedValue([]),
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
changedPaths: vi.fn().mockResolvedValue([]),
2026-05-10 23:12:26 +02:00
};
const sessionWorktreeService = {
create: vi.fn().mockResolvedValue({
chatId: 'j1',
workdir: '/tmp/wt',
branch: 'session/j1',
baseSha: 'b',
createdAt: new Date(),
git: scopedGit,
config: {},
}),
cleanup: vi.fn(),
};
const agentRunner = { runLoop: vi.fn().mockResolvedValue({ stopReason: 'natural' }) };
const gitService = {
revParseHead: vi.fn().mockResolvedValue('base'),
listFilesAtHead: vi.fn().mockResolvedValue([]),
getFileAtCommit: vi.fn(),
squashMergeIntoMain: vi
.fn()
.mockResolvedValue({ ok: true, squashSha: 'sq', touchedPaths: ['raw-sources/c1/fake/s/a.yml'] }),
};
const lockingService = {
withLock: vi.fn().mockImplementation(async (_k: string, fn: () => Promise<unknown>) => fn()),
};
const appSettingsService = {
settings: {
ai: { slValidation: { probeRowCount: 1 } },
llm: { memoryIngestionModel: 'test-model' },
},
};
const skillsRegistry = {
listSkills: vi.fn().mockResolvedValue([]),
getSkill: vi.fn().mockResolvedValue(null),
buildSkillsPrompt: vi.fn().mockReturnValue(''),
stripFrontmatter: vi.fn().mockImplementation((s: string) => s),
};
const promptService = {
loadPrompt: vi.fn().mockResolvedValue('base-framing'),
};
const wikiService = {
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
forWorktree: vi.fn(),
listPageKeys: vi.fn().mockResolvedValue([]),
2026-05-10 23:12:26 +02:00
readPage: vi.fn().mockResolvedValue(null),
syncFromCommit: vi.fn().mockResolvedValue(undefined),
};
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
wikiService.forWorktree.mockReturnValue(wikiService);
2026-05-10 23:12:26 +02:00
const knowledgeSlRefs = {
syncFromWiki: vi.fn().mockResolvedValue({ inserted: 1, deleted: 0 }),
};
const knowledgeIndex = {
listPagesForUser: vi.fn().mockResolvedValue([]),
};
const semanticLayerService = {
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
forWorktree: vi.fn(),
2026-05-10 23:12:26 +02:00
listFilesForConnection: vi
.fn()
.mockImplementation((connectionId: string) =>
Promise.resolve(connectionId === 'warehouse-2' ? ['looker__orders.yaml'] : []),
),
loadAllSources: vi
.fn()
.mockImplementation((connectionId: string) =>
fix(context): merge overlay columns onto manifest columns by name (#94) * fix(context): merge overlay columns onto manifest columns by name composeOverlay was appending overlay columns to the manifest column list, producing duplicate entries when dbt/metabase overlays declared a column just to attach descriptions. The duplicates carried no `type`, so the pydantic SourceDefinition rejected them at semantic-query time and broke `ktx sl query` for every overlay-backed measure. Now overlay columns match base columns by name (case-insensitive): same-name entries merge onto the manifest (overlay fields win, type/role fall back to the base, descriptions merge per source key) and only new names append. * refactor(sl): split overlay columns from column_overrides and enforce TS/Python wire contract Overlay sources now have two distinct collections: `columns:` for computed columns (requiring `expr` + `type`) and `column_overrides:` for metadata patches to inherited manifest columns. Composing or loading an overlay that mixes the two — or references an unknown column — fails with a typed error. Introduce `ResolvedSemanticLayerSource` / `resolvedSourceSchema` / `toResolvedWire` as the strict shape sent to the Python engine, and add a schema contract test that diffs Zod against the Pydantic JSON schema dumped by `python -m semantic_layer dump-schema`. `SourceDefinition` is now `extra="forbid"` on the Python side. `loadAllSources` surfaces per-file load errors instead of swallowing them, so validation/query paths can report manifest shard parse failures. * fix(context): make scan description generation resilient and quiet A transient sampleTable failure during ingest used to take out every table in a connection: generateTableDescription returned a hardcoded 'Table not found' string into descriptions.ai, and KtxDescriptionGenerator was constructed without a logger, so the failure left no trail anywhere. - sampleTable / sampleColumn calls retry 3x with 200/400/800ms backoff, honouring KtxScanContext.signal via a new KtxAbortedError. - On retry exhaustion or missing capability, table generation falls back to a metadata-only prompt built from column name / native type / comment / rawDescriptions. The column path follows the same rule -- call the LLM when any of samples or rawDescriptions are available; skip only when both are absent. - Logger is now threaded from KtxScanContext into the generator. Failures emit structured KtxScanWarning entries (new description_fallback_used code, plus existing sampling_failed / enrichment_failed / connector_capability_missing). ktx scan groups warnings by code so a batch of identical failures collapses to one summary line plus sample. - Returns null on failure instead of the 'Table not found' sentinel; the manifest writer's existing guard already skips empty descriptions, so schema YAML no longer carries misleading text. SCAN_MANAGED_DESCRIPTION_KEYS already strips stale 'ai' on merge, so existing YAML clears on next run. Also suppress AI SDK v6 'system in messages' warning: pull system messages out of KtxMessageBuilder.wrapSimple's output via a new splitKtxSystemMessages helper and pass them top-level to generateText (preserves cacheControl providerOptions on the SystemModelMessage). Agent-runner's local splitSystemPromptMessages dedupes onto the shared helper. * test(docs): align examples-docs assertions with revamped docs PR #103 (setup/guide doc revamp) reworded several CLI examples and connection labels; the assertions in scripts/examples-docs.test.mjs still referenced the pre-revamp wording and were failing in CI on main. Update the regexes to match the post-revamp content: - drop the `--json` flag from the sl-query example expectation - move the `Driver:` / `Status: ok` probe to the connection reference, which is where that output now lives (driver id is lowercase `postgres`, not the display name `PostgreSQL`) - drop the obsolete `Install \`uv\`...` troubleshooting line - accept `<connectionId>` everywhere; the docs no longer use the hyphenated `<connection-id>` form - match the `warehouse` connection id used in the quickstart instead of the `postgres-warehouse` id only used in the README and setup ref * fix(sl): skip TS/Python schema contract test when uv is unavailable The TypeScript checks CI job does not install uv or Python, so the module-level `execFileSync('uv', ...)` in schemas.contract.test.ts threw ENOENT and failed the suite. Wrap the schema dump in a try/catch and guard the describe block with `describe.skipIf` so the test skips in environments without uv. Local dev and any CI job that has uv on PATH still runs the cross-language contract assertion.
2026-05-15 02:11:04 +02:00
Promise.resolve({
sources: connectionId === 'warehouse-2' ? [{ name: 'looker__orders' }] : [],
loadErrors: [],
}),
),
2026-05-10 23:12:26 +02:00
};
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
semanticLayerService.forWorktree.mockReturnValue(semanticLayerService);
2026-05-10 23:12:26 +02:00
const slSearchService = {
indexSources: vi.fn().mockResolvedValue(undefined),
};
const slSourcesRepository = {};
const slValidator = { validateSingleSource: vi.fn().mockResolvedValue({ errors: [], warnings: [] }) };
const toolsetFactory = {
createIngestWuToolset: vi.fn().mockReturnValue({
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
toRuntimeTools: vi.fn().mockReturnValue({}),
2026-05-10 23:12:26 +02:00
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
}),
};
const configService = {
enqueueCommitMessageJobForExternalCommit: vi.fn().mockResolvedValue(undefined),
};
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
const contentCache = {
findCompletedResult: vi.fn().mockResolvedValue(null),
findLatestCompletedResult: vi.fn().mockResolvedValue(null),
saveCompletedResult: vi.fn().mockResolvedValue(undefined),
saveFailedResult: vi.fn().mockResolvedValue(undefined),
deleteResult: vi.fn().mockResolvedValue(undefined),
listRunResults: vi.fn().mockResolvedValue([]),
};
2026-05-10 23:12:26 +02:00
return {
runsRepo,
provenanceRepo,
reportsRepo,
canonicalPins,
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
contentCache,
2026-05-10 23:12:26 +02:00
adapter,
registry,
diffSetService,
contextEvidenceIndex,
pageTriage,
sessionWorktreeService,
agentRunner,
gitService,
lockingService,
slValidator,
appSettingsService,
skillsRegistry,
promptService,
wikiService,
knowledgeSlRefs,
knowledgeIndex,
semanticLayerService,
slSearchService,
slSourcesRepository,
toolsetFactory,
configService,
};
};
const buildRunner = (deps: ReturnType<typeof makeDeps> = makeDeps(), overrides: Partial<IngestBundleRunnerDeps> = {}) =>
new IngestBundleRunner({
runs: deps.runsRepo as any,
provenance: deps.provenanceRepo as any,
registry: deps.registry as any,
diffSetService: deps.diffSetService as any,
contextEvidenceIndex: deps.contextEvidenceIndex,
pageTriage: deps.pageTriage as any,
sessionWorktreeService: deps.sessionWorktreeService as any,
agentRunner: deps.agentRunner as any,
gitService: deps.gitService as any,
lockingService: deps.lockingService as any,
storage: {
2026-05-10 23:51:24 +02:00
homeDir: '/tmp/ktx-test',
systemGitAuthor: { name: 'ktx Test', email: 'system@ktx.local' },
2026-05-10 23:51:24 +02:00
resolveUploadDir: (uploadId) => `/tmp/ktx-test/ingest-uploads/${uploadId}`,
resolvePullDir: (jobId) => `/tmp/ktx-test/ingest-pulls/${jobId}`,
resolveTranscriptDir: (jobId) => `/tmp/ktx-test/run/wu-transcripts/${jobId}`,
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
resolveTracePath: (jobId) => `/tmp/ktx-test/ingest-traces/${jobId}/trace.jsonl`,
},
settings: {
probeRowCount: 1,
memoryIngestionModel: 'test-model',
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
cliVersion: '0.0.0-test',
2026-05-10 23:12:26 +02:00
},
skillsRegistry: deps.skillsRegistry as any,
promptService: deps.promptService as any,
wikiService: deps.wikiService as any,
knowledgeSlRefs: deps.knowledgeSlRefs as any,
knowledgeIndex: deps.knowledgeIndex,
semanticLayerService: deps.semanticLayerService as any,
slSearchService: deps.slSearchService as any,
slSourcesRepository: deps.slSourcesRepository as any,
connections: {
listEnabledConnections: vi.fn().mockResolvedValue([]),
getConnectionById: vi.fn().mockResolvedValue({ id: 'c1', name: 'warehouse', connectionType: 'POSTGRES' }),
executeQuery: vi.fn().mockResolvedValue({ headers: [], rows: [] }),
},
reports: deps.reportsRepo as any,
canonicalPins: deps.canonicalPins,
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
contentCache: deps.contentCache,
2026-05-10 23:12:26 +02:00
slValidator: deps.slValidator as any,
toolsetFactory: deps.toolsetFactory as any,
commitMessages: {
enqueueForExternalCommit: deps.configService.enqueueCommitMessageJobForExternalCommit,
},
embedding: {
maxBatchSize: 10,
computeEmbedding: async () => [0],
computeEmbeddingsBulk: async (texts: string[]) => texts.map(() => [0]),
},
...overrides,
});
describe('IngestBundleRunner — FIFO-per-connection', () => {
let spy: any;
beforeEach(() => {
spy = vi.fn();
});
it('serializes two jobs on the same connectionId', async () => {
const runner = buildRunner();
(runner as any).runInner = async (job: any) => {
spy(job.jobId);
await new Promise((r) => setTimeout(r, 5));
spy(`done-${job.jobId}`);
return {
runId: 'r',
syncId: 's',
diffSummary: { added: 0, modified: 0, deleted: 0, unchanged: 0 },
workUnitCount: 0,
failedWorkUnits: [],
artifactsWritten: 0,
commitSha: null,
};
};
const p1 = runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'u1' },
});
const p2 = runner.run({
jobId: 'j2',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'u2' },
});
await Promise.all([p1, p2]);
expect(spy.mock.calls.map((c: unknown[]) => c[0])).toEqual(['j1', 'done-j1', 'j2', 'done-j2']);
});
it('runs jobs on different connections in parallel', async () => {
const runner = buildRunner();
const d1 = deferred<void>();
const d2 = deferred<void>();
(runner as any).runInner = async (job: any) => {
spy(`start-${job.jobId}`);
if (job.jobId === 'j1') {
await d1.promise;
}
if (job.jobId === 'j2') {
await d2.promise;
}
return {
runId: 'r',
syncId: 's',
diffSummary: { added: 0, modified: 0, deleted: 0, unchanged: 0 },
workUnitCount: 0,
failedWorkUnits: [],
artifactsWritten: 0,
commitSha: null,
};
};
const p1 = runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'u1' },
});
const p2 = runner.run({
jobId: 'j2',
connectionId: 'c2',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'u2' },
});
await new Promise((r) => setTimeout(r, 10));
expect(spy.mock.calls.map((c: unknown[]) => c[0]).sort()).toEqual(['start-j1', 'start-j2']);
d1.resolve();
d2.resolve();
await Promise.all([p1, p2]);
});
});
describe('IngestBundleRunner — Stages 1 → 7', () => {
it('runs the full pipeline, creates a run row, stages files, chunks, squashes, writes provenance', async () => {
const deps = makeDeps();
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const result = await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(deps.runsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'j1', connectionId: 'c1', sourceKey: 'fake', trigger: 'upload' }),
);
expect(deps.adapter.detect).toHaveBeenCalled();
expect(deps.adapter.chunk).toHaveBeenCalled();
expect(result.workUnitCount).toBe(1);
expect(deps.diffSetService.compute).toHaveBeenCalled();
expect(deps.gitService.squashMergeIntoMain).toHaveBeenCalledWith(
'session/j1',
expect.any(String),
expect.any(String),
expect.stringContaining('ingest(fake): j1'),
);
expect(deps.provenanceRepo.insertMany).toHaveBeenCalled();
expect(result.commitSha).toBe('sq');
expect(deps.runsRepo.markCompleted).toHaveBeenCalledWith('run-1', expect.any(Object), 'completed');
// Single touched path → path-scoped diff for the LLM commit-message note.
expect(deps.configService.enqueueCommitMessageJobForExternalCommit).toHaveBeenCalledWith(
{ commitHash: 'sq' },
expect.stringContaining('ingest(fake): j1'),
'raw-sources/c1/fake/s/a.yml',
);
});
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
it('uses the rate-limit governor for work-unit start slots', async () => {
const deps = makeDeps();
const acquireWorkSlot = vi.fn(async () => vi.fn());
const runner = buildRunner(deps, {
settings: {
probeRowCount: 1,
memoryIngestionModel: 'test-model',
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
cliVersion: '0.0.0-test',
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
workUnitMaxConcurrency: 2,
rateLimitGovernor: { acquireWorkSlot, subscribe: vi.fn(() => vi.fn()) } as never,
},
});
deps.adapter.chunk.mockResolvedValue({
workUnits: [
{ unitKey: 'u1', rawFiles: ['a.yml'], peerFileIndex: [], dependencyPaths: [] },
{ unitKey: 'u2', rawFiles: ['b.yml'], peerFileIndex: [], dependencyPaths: [] },
],
});
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([
['a.yml', 'h1'],
['b.yml', 'h2'],
]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(acquireWorkSlot).toHaveBeenCalledTimes(2);
});
it('passes the job abort signal into rate-limit work-unit slots', async () => {
const deps = makeDeps();
const controller = new AbortController();
const acquireWorkSlot = vi.fn(async () => vi.fn());
const runner = buildRunner(deps, {
settings: {
probeRowCount: 1,
memoryIngestionModel: 'test-model',
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
cliVersion: '0.0.0-test',
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
workUnitMaxConcurrency: 1,
rateLimitGovernor: { acquireWorkSlot, subscribe: vi.fn(() => vi.fn()) } as never,
},
});
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
{ jobId: 'j1', abortSignal: controller.signal, startPhase: () => new TestJobContext('j1', null, async () => undefined, async () => undefined) } as any,
);
expect(acquireWorkSlot).toHaveBeenCalledWith(controller.signal);
});
it('does not convert aborted work-unit agent loops into failed work units', async () => {
const deps = makeDeps();
const controller = new AbortController();
deps.agentRunner.runLoop.mockImplementation(async () => {
controller.abort();
throw new DOMException('Aborted', 'AbortError');
});
const runner = buildRunner(deps, {
settings: {
probeRowCount: 1,
memoryIngestionModel: 'test-model',
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
cliVersion: '0.0.0-test',
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
workUnitMaxConcurrency: 1,
},
});
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await expect(
runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
{ jobId: 'j1', abortSignal: controller.signal, startPhase: () => new TestJobContext('j1', null, async () => undefined, async () => undefined) } as any,
),
).rejects.toThrow(/Aborted/);
expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1');
expect(deps.reportsRepo.create).not.toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
failedWorkUnits: expect.arrayContaining(['u1']),
}),
}),
);
});
it('emits trace and memory-flow status for rate-limit waits', async () => {
const deps = makeDeps();
let subscriber: ((state: any) => void) | undefined;
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput());
const runner = buildRunner(deps, {
settings: {
probeRowCount: 1,
memoryIngestionModel: 'test-model',
Resumable and fault-tolerant source ingest (spec #22) (#315) * docs: add spider2-specs handoff directory for benchmark-driven feature specs * feat(cli): connection-scoped wiki pages Add an optional `connections` frontmatter field so database-specific wiki knowledge can be scoped to a connection without polluting searches about other databases, while page keys stay a flat, globally-unique namespace. - connections: single string or list; absent/empty ⇒ unscoped (applies to all) - wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching pages, filtered at the disk-load seam so all three search lanes draw their candidate pool from the already-scoped set (not a post-filter) - wiki_write accepts connections with REPLACE semantics and rejects a connection-scoped write whose key collides with a disjoint-connection page (data-loss guard; hard error, no silent clobber) - explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are validated against ktx.yaml via a shared assertConfiguredConnectionId, which also closes the prior gap where memory_ingest's connectionId was unvalidated; persisted ids absent from config warn (not fail) in `ktx status` - prompt guidance in the wiki_capture skill and external-ingest prompt; the session connectionId is surfaced to the memory agent and ingest work units Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved to spider2-specs/done/. * docs(spider2-specs): add specs/ refinement stage and composite-key join spec Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are the durable artifact; intake drafts move to done/ on ship) and add a MEDIUM-priority spec for multi-column composite-key join detection found during the first sqlite smoke test. * feat(cli): add --verbatim ingest mode for authoritative documents Store each --text/--file document body unchanged as a GLOBAL wiki page instead of routing it through the memory agent, which may rewrite, condense, or re-title it. The LLM derives only metadata (summary, tags, sl_refs) and only for frontmatter fields the document does not already set; the stored body is written by code and never edited. - Deterministic page key: files derive it from the filename, inline text from its leading Markdown heading (headless inline text is rejected — pass it as --file instead). - Idempotent: re-running the same body is a no-op; a different body at the same key fails loudly rather than overwriting. - Works with llm.provider.backend: none, deriving a degraded summary from the heading or first sentence. - Existing frontmatter (including unmodeled fields like effective_date) passes through untouched; --connection-id scopes the page. * feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged. Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool. Both spider2-specs intake drafts move to done/ with implementation notes. * feat(cli): tolerate objects that fail introspection during scan Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests. * docs(spider2-specs): add 06-scan-tolerate-broken-objects spec * feat(cli): generalize analytics fan-out rule to multi-hop join chains The ktx-analytics skill's fan-out rule only reliably caught single-hop inflation; agents still silently fanned out on multi-hop chains where the offending one-to-many join sits several hops below the SUM/COUNT and is easy to miss. Rewrite the Composition rule so the danger reads as cumulative across the whole chain (pre-aggregate per measure-owning table), add an affirmative grain-verification habit (default: pre-aggregate to grain; escape hatch: COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must pre-aggregate), and add one generic wrong-vs-right worked example. Content-only and dialect-agnostic; no new tool, flag, or config. Implements spider2-specs/specs/09 and annotates spec 07's one-example constraint as superseded. * feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft Extend the analytics skill's <sql_craft> with three correctness habits and route the dialect-specific halves through sql_dialect_notes: - Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for "each/every/all/per" questions, defaulted by measure additivity. - Time-series windows (spec 11): explicit cumulative frames, calendar-range rolling windows with minimum-periods guards, and period-over-period via LAG. - Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in one early CTE, and confirm coverage with a failure-detecting cast. Add per-dialect Series, Rolling window, and Safe cast notes to all seven dialect files so the skill stays dialect-agnostic while the engine-specific syntax lives in sql_dialect_notes. Tests updated and passing (19). * docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions Refined specs and completion records for the panel-completeness spine (10), time-series window recipes (11), and text-encoded numeric parsing (12) implemented in the preceding commit. * docs(spider2-specs): add backlog intake drafts 13-14 - 13: canonical authoritative-source measures - 14: output-completeness final check * skill(analytics): spec 14 output-completeness + iter1 (active column planning) Bundles two changes (entangled in SKILL.md; future spider2 iterations land as separate commits): - spec 14 (output-completeness): multi-part "answer every requested output" rule + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics skill-content test updated; intake draft -> done/, refined spec added. - iter1 experiment: spec 14's passive end-check did not change behavior on the benchmark's output-completeness failures, so (a) the Plan step now writes the exact output-column list UP FRONT as a contract the final SELECT must match, and (b) "expose identity" -> "project BOTH the entity id and its name" (covers both omission directions). All generic craft. Driven by the Spider 2.0-Lite failure analysis (incomplete output was the largest failure bucket); benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): iter2 — deterministic order in string/array aggregation GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft. Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the within-string element order (right elements, wrong order); benchmark as motivation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mcp): structured, leveled logging for the MCP server Add one synchronous pino logger per MCP server process, written through the io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty (sync, in-process) when it is. Every tool call logs tool.start with its raw params BEFORE the handler runs and tool.end after (info / warn past KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a runaway sql_execution leaves a recoverable start line with its exact SQL and no matching end. HTTP logs session.open/close and wires the previously-dead transport.onerror to transport.error; stdio routes its transport error through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing mcp_request_completed telemetry and registerParsedTool are unchanged; no worker/async transport and no redaction in v1 (logs are local-only). Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves the intake draft to done/. * feat(mcp): report uptimeMs in MCP server /health The /health endpoint now includes uptimeMs (monotonic elapsed time since the server started), mirroring the Python daemon's uptime_ms telemetry field. * feat(cli): bound read-query execution with a per-connection deadline Enforce one shared query deadline (default 30s, overridable per connection via query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of hanging the MCP server. - New shared contract context/connections/query-deadline.ts (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to the shared warehouse schema; BigQuery's job_timeout_ms removed. - SQLite runs the read query in a short-lived forked child process and enforces the deadline with SIGKILL. worker_threads + terminate() was tried first but cannot interrupt a synchronous better-sqlite3 scan (the native loop never yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free. - Remote connectors apply a real server-side statement timeout and re-wrap their own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/ ETIMEOUT, BigQuery jobTimeoutMs. - Relationship validation skips a candidate to review on a deadline timeout instead of aborting the pass; the deadline surfaces through the existing MCP pino logger as a matched tool.start/tool.end(error) pair (no new logging code). Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts that was breaking tsc -p tsconfig.test.json. * docs(spider2-specs): mark spec 16 (bounded query execution) done Append Implementation notes to the refined spec (what shipped, where, and the worker-thread -> child-process+SIGKILL deviation with its evidence) and move the intake draft from todo/ to done/. * skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career Three generic interpretation rules: a named business measure (sales/revenue/spend) means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between events not a magnitude column; "highest across several achievements" aggregates per metric over the whole history. All three demonstrably FIRE (verified on local008/003/152 SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different axis (source-column / grouping). Generic craft; benchmark only as motivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set Two generic answer-completeness refinements: - Selecting the extreme group (lowest/highest count over a period/category domain) must rank over the COMPLETE spine, not only groups with fact rows — an empty period is a genuine 0 and often the true minimum. - An aggregate scoped to a per-entity selected set ('avg revenue per actor in those top-3 films') is computed ACROSS that set, distinct from the per-item value; project both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure - spine-for-extreme: concrete cue that a zero-row period never appears in a GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank. - aggregate-over-selected-set: top-N selection ranks by the named ranking measure (the item's own revenue), independent of the per-item share that feeds the aggregate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter3 — comparison-between-two-extremes is one wide row Distinguishes a cross-item comparison ('the difference between the highest and lowest month' -> single wide row, both extremes side by side + the comparison column) from 'report a metric for each group' (-> stays long). Generic, question- derived; targets the wide-vs-long shape gap without affecting per-group long output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter4 — anchor a period bucket to the named lifecycle event When a record carries multiple lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named *completed state* by period ("delivered orders by month", "shipped items per week"), bucket the period by that named event's own timestamp, not the record-creation timestamp; the state value is the qualifying filter, the matching timestamp is the time anchor. Wording priority is explicit — purchased/placed/ created/submitted/ordered keep the start-event timestamp — and a non-temporal state filter (counts by customer/city/seller with no period) introduces no anchor. Generic analytics craft: counting completed-state records by their creation date silently answers "records that later reached that state, grouped by when they started" instead of the question asked. Surfaced via the spider2-autofix loop; FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/ semantic-layer lifecycle descriptions, no gold dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis When a question groups/filters/sequences web pages by a path/url column, sample its distinct values; if the data itself shows /route and /route/ variants for the same page context, canonicalize in an early CTE (preserve / as root, strip trailing slashes from non-root paths, map an observed empty path to / only when the column is a URL path with blank root-page events) and use the canonical path everywhere above. Explicitly forbids inventing aliases the data doesn't show: no merging different route names, no stripping query/fragment/host/scheme, no lowercasing, and no canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs. Generic web-analytics craft: raw request logs routinely store the same user-visible page with and without a trailing slash, so grouping raw labels silently splits one page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2); FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain question wording + solver-observed distinct values, no gold dependency). The rule fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts), local331 residual is a separate sequence-semantics axis beyond canonicalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate When a question first selects a group of entities ("the top 5 actors", "these products") and then asks what count/share/percentage of a DIFFERENT subject domain relates to *these* selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: count DISTINCT subject ids once across the selected entities and return one collective value at the subject-domain grain — not one row per selected entity (which double-counts subjects related to more than one entity and answers a different question). Narrowly guarded: emit one row per entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"). The collective-coverage cousin of the existing per-entity selected-set rule. Generic analytics craft (per-entity metric vs set-level coverage). Surfaced via the spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened, restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned (union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing per-entity top-N guards (local023/024/029/212/221 stayed long). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a dimension table used only to attach a display attribute silently discards every fact row whose key has no parent when the dimension is incomplete (trimmed catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe over which shares/averages/medians are computed. Guidance: LEFT JOIN pure enrichment; inner-join a dimension only when intended as a filter; key the aggregate/GROUP BY on the fact column, not the dimension column. Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS, official scorer) — solver dropped the gratuitous products inner-join and recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but remain gold-convention-blocked. Guards local061/067 held. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer) Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the delivery timestamp) during enrichment so the correct time anchor is the default for any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(connectors): accept leading underscore in connection/identifier ids The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an underscore everywhere except the first character, so a connection id / database name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES) could never be ingested or queried. Allow a leading underscore across all 16 duplicated validators (connection ids, source ids, page/wiki keys, warehouse- verification tool schemas). Path-safety is unaffected — '.' and '/' remain excluded, and assertSafePathToken still blocks traversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): generic geospatial query guidance Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/ ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the entity geometry, build an area-of-interest polygon, test with the engine's containment/proximity/overlap predicate; mind lon/lat argument order). Steers the solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted geospatial predicates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): parse code/dependency text by language grammar Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by the language or manifest format (Java import keep-package-path allowing underscores/ mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse JSON cell source before language rules; JSON manifests flatten the dependency object keys), stripping comments/prose and splitting multi-import lines; (2) on a de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the weight column from the population the question names, not silently. Steers off one broad regex that drops valid identifiers and matches prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): source filters/dates/measures from the owning fact grain Add a <sql_craft> rule for joined fact tables at different grains (parent order vs child line item): read each predicate, calendar bucket, and measure from the table whose grain the question names, not whichever is in scope post-join. An order-grain filter ("orders that are Complete", "the order's creation date") must come from the parent even though the child carries its own status/created_at; line price/cost come from the child. Mirror at metric grain: don't combine a parent-grain count with child rows (num_of_item * SUM(line_price) per line) — aggregate each measure at its own grain before combining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration When an entity carries a multi-valued classification array (IPC/CPC codes, tags) and the methodology counts entities-per-class or a concentration/diversity metric (HHI, originality, share), pick ONE representative per entity first (the array's main/primary/first flag, else a defined fallback like most-frequent), then aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a count of entities. Unnesting the array otherwise multiplies an entity's weight by its code count, inflating per-class frequencies and skewing the ranking/score. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(connectors): introspect BigQuery datasets hosted in foreign projects A dataset_ids/dataset_id entry may now be written `project.dataset` to introspect a dataset hosted in another project while query jobs still bill to credentials.project_id. Entries are parsed once at the config boundary into canonical {project, dataset} pairs; introspection, primary-key discovery, testConnection, getTableRowCount, and listTables (grouped per project) all resolve in the dataset's own project, and scanned tables are labeled with that project so sampling, distinct-value, and read queries resolve. Bare entries are unchanged. Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md. * feat(scan): durable, resumable, bounded relationship detection during enrichment Move the enrichment persistence boundary to the cost boundary and bound the open-ended relationship stage (spec 19). - Checkpoint descriptions + embeddings into the queryable `_schema` manifest (and the raw enrichment artifacts) before relationship detection runs, via a new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted, budget-truncated, or failed relationship stage now degrades to "no joins", never "no descriptions". - Resume the enrichment cache by content identity: re-key the SQLite stage store on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes finished descriptions/embeddings instead of re-paying for LLM work. The disposable cache recreates its table if the on-disk key shape differs. - Make the relationship stage observable and bounded: a sticky wall-clock budget (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress + honored `ctx.signal`, threaded through profiling, validation, and composite detection. On exhaustion/abort it stops scheduling, finalizes, and returns a partial result instead of throwing or hanging. - Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason` + recoverable `relationship_detection_partial` warning). A graceful partial saves as a completed stage and resumes cheaply; raising the budget changes inputHash and forces a fresh, fuller run. A process killed mid-stage saves nothing. Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation notes to specs/19 and move the intake draft to done/. Also carries the in-tree per-table enrichment LLM timeout work it builds on (`description-generation.ts` + the `enrichment_timeout` warning code), which is intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a separately-building commit. * feat(scan): bound + retry the per-table enrichment LLM call The batched table-description call had no retry (sampleTable retried 3x, this did not), so a single transient backend error (e.g. an overloaded/burst rejection when many tables enrich concurrently) silently nulled a whole table's descriptions — observed dropping ~70% of a db's tables during a bad window despite ample quota. - Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS). - Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried (one wedge stays one timeout, not 3x). - Granular per-table progress + start/done/retry/timeout logging. Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable; this makes more of them complete. * feat(scan): survive a hung LLM enrichment backend and resume descriptions Two compounding failure modes on the per-table description-enrichment path (spec 20): Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout. Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output). Spec 20 refined + intake draft moved to done/. * feat(scan): selective enrichment stages (--stages) + per-stage cache keys Split the single coarse enrichment cache key into per-stage hashes (descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding identity + description digest; relationships <- snapshot + relationship settings + LLM identity), so changing one stage's inputs invalidates only that stage and never throws away the expensive per-table descriptions on an unrelated edit. Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an already-ingested connection: a named stage bypasses the completed-stage short-circuit while the per-table descriptions resume record still skips already-enriched tables, and unselected stages are left untouched on disk. Feed embeddings + relationships their description context from the on-disk _schema when descriptions do not run this invocation, and carry descriptions into the llmProposals evidence packet (closing a latent gap on the full-run path too). Surface an enrichment_stage_stale warning when an unselected stage's inputs have drifted, rather than silently cascading the work. Implements spider2-specs/specs/21-selective-enrichment-stages.md. * test(analytics): realign SKILL.md acceptance test with the evolved skill Three assertions in analytics-skill-content.test.ts drifted from the analytics SKILL.md as later iterations edited the skill without updating the test: - the sub-heading was renamed Window functions -> Ordering & aggregation determinism (iter2), so follow the source name; - the rule "Expose identity, not just the label" was renamed to "Project BOTH identity and label" (spec 14), so match the new wording; - the dialect-FQTN guard false-positived on the Java package example com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the guard stays at full strength without weakening it. * fix(scan): --stages subset must not delete unselected stages' on-disk artifacts A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions from the written _schema. runLocalScan writes the structural manifest shard from the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as scan-managed and overwrites them with whatever the run emits — none, on a subset that skips descriptions. Enrichment then read the already-wiped shard via loadPriorDescriptions and had nothing to restore. runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run if descriptions ran, else loaded from the on-disk _schema) instead of [], and runLocalScan captures the prior descriptions before the structural write and feeds them to both the structural write and enrichment, so an unselected stage's artifacts survive. Joins were already preserved for --stages descriptions via the manual/inferred preservedJoins path. Tests: a full runLocalScan --stages relationships path test (RED without the fix, GREEN with it — the earlier unit test missed the structural-pre-write ordering), plus enrichment-layer contract tests for both directions. Validated live on northwind: --stages relationships keeps all 110 descriptions + 22 joins (was wiping to 0); --stages descriptions restores descriptions from the spec-20 resume record (no LLM calls) while keeping joins. * feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md): - Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params), dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard. - Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection predicates, areal allocation via ST_AREA(ST_INTERSECTION()). - SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note. Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits). * spec(ingest): resumable + fault-tolerant source ingest (#22) Refined spec for two source-ingest durability gaps surfaced by a real user report on a ~2-day dbt ingest: (1) interrupted runs restart every work unit from scratch (no cross-run reuse), and (2) the final integration gate is all-or-nothing — one unfixable artifact discards the whole run. Design: automatic content-keyed work-unit resume reusing the scan durability primitive (specs 19/20), plus a deterministic dangling-edge prune that replaces the fatal final-gate throw so a single bad model costs only that model, not the run. Prune operates on the integrated tree and never poisons the cache, so resume and prune self-heal. * refactor(scan): route enrichment resume through shared cache * feat(ingest): replay cached work unit patches * refactor(ingest): return structured final gate findings * feat(ingest): prune final gates without LLM repair * docs(ingest): document final gate pruning * test(ingest): cover stale work unit cache recompute * fix(ingest): refresh stale cache recompute metadata * test(ingest): cover missing-target prune and self-heal * fix(ingest): defer pruneable final gate findings * fix(ingest): replay pruned cached work unit intent * chore(ingest): verify resumable source ingest self-heal * test(ingest): cover final gate prune source path resolution * fix(ingest): resolve final gate prune sources canonically * fix: defer wiki ref cleanup out of stage 3 * test: cover non-cascading final gate join pruning * test: cover intrinsic final gate source drops * docs(spec): record implementation notes for resumable source ingest (#22) * fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text - final gate: drop a dropped source's dangling join edges from every owner on the connection, including untouched siblings the touched-scoped gate never revisits, so a committed orphan join can't break SL queries - work-unit cache: drop the stored patch text; replay re-derives the diff from the before/after artifact snapshots, carrying each touched file only once - scan enrichment: checkpoint recomputed embeddings before the kill-prone relationship stage even when descriptions load from disk, using the best-available description set so the manifest merge can't delete them - sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile share one listing path * fix(scan): accept relationships mode in enrichment state metadata Listing run stages after a relationships-mode scan threw "Invalid scan enrichment cache metadata" because the parser hand-enumerated only the structural/enriched modes while a relationships scan persists its stage with mode 'relationships'. Derive the mode and stage allowlists from the canonical KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check cannot drift from the type again. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
cliVersion: '0.0.0-test',
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
rateLimitGovernor: {
acquireWorkSlot: vi.fn(async () => vi.fn()),
subscribe: vi.fn((cb: (state: any) => void) => {
subscriber = cb;
return vi.fn();
}),
} as never,
},
});
(runner as any).runInner = async (_job: any, ctx: any) => {
subscriber?.({
kind: 'wait_tick',
provider: 'claude-subscription',
rateLimitType: 'five_hour',
resumeAtMs: 2_000,
remainingMs: 1_000,
});
ctx.memoryFlow.emit({ type: 'report_created', runId: 'run-1' });
return {
runId: 'run-1',
syncId: 'sync-1',
diffSummary: { added: 0, modified: 0, deleted: 0, unchanged: 0 },
workUnitCount: 0,
failedWorkUnits: [],
artifactsWritten: 0,
commitSha: null,
};
};
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
{ memoryFlow } as any,
);
expect(memoryFlow.snapshot().events).toContainEqual(
expect.objectContaining({
type: 'rate_limit_wait',
provider: 'claude-subscription',
rateLimitType: 'five_hour',
resumeAtMs: 2_000,
remainingMs: 1_000,
}),
);
});
it('fails before squash when reconciliation leaves a touched wiki page with dangling refs', async () => {
const deps = makeDeps();
let currentToolSession: any = null;
const scopedWiki = {
listPageKeys: vi.fn().mockResolvedValue(['page-a']),
readPage: vi.fn().mockImplementation((_scope: string, _scopeId: string | null, key: string) => {
if (key === 'page-a') {
return Promise.resolve({
pageKey: 'page-a',
frontmatter: { summary: 'Page A', usage_mode: 'auto', refs: ['missing-page'] },
content: 'See [[missing-page]].',
});
}
return Promise.resolve(null);
}),
};
deps.wikiService.forWorktree.mockReturnValue(scopedWiki);
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
currentToolSession = toolSession;
return {
toRuntimeTools: vi.fn().mockReturnValue({}),
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
};
});
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
currentToolSession.actions.push({ target: 'sl', type: 'updated', key: 'orders', detail: 'Orders source' });
}
if (params.telemetryTags.operationName === 'ingest-bundle-reconcile') {
currentToolSession.actions.push({ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' });
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await expect(
runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
}),
).rejects.toThrow(/wiki references target missing page\(s\): page-a -> missing-page/);
expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1');
expect(deps.gitService.squashMergeIntoMain).not.toHaveBeenCalled();
});
it('allows reconciliation to save circular wiki refs once both pages exist', async () => {
const deps = makeDeps();
let currentToolSession: any = null;
const scopedWiki = {
listPageKeys: vi.fn().mockResolvedValue(['page-a', 'page-b']),
readPage: vi.fn().mockImplementation((_scope: string, _scopeId: string | null, key: string) => {
if (key === 'page-a') {
return Promise.resolve({
pageKey: 'page-a',
frontmatter: { summary: 'Page A', usage_mode: 'auto', refs: ['page-b'] },
content: 'See [[page-b]].',
});
}
if (key === 'page-b') {
return Promise.resolve({
pageKey: 'page-b',
frontmatter: { summary: 'Page B', usage_mode: 'auto', refs: ['page-a'] },
content: 'See [[page-a]].',
});
}
return Promise.resolve(null);
}),
};
deps.wikiService.forWorktree.mockReturnValue(scopedWiki);
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
currentToolSession = toolSession;
return {
toRuntimeTools: vi.fn().mockReturnValue({}),
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
};
});
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
currentToolSession.actions.push({ target: 'sl', type: 'updated', key: 'orders', detail: 'Orders source' });
}
if (params.telemetryTags.operationName === 'ingest-bundle-reconcile') {
currentToolSession.actions.push(
{ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' },
{ target: 'wiki', type: 'created', key: 'page-b', detail: 'Page B' },
);
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const result = await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(result.failedWorkUnits).toEqual([]);
expect(deps.gitService.squashMergeIntoMain).toHaveBeenCalled();
expect(deps.runsRepo.markFailed).not.toHaveBeenCalled();
});
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
it('threads target warehouse connection names into WorkUnit and reconcile tool sessions', async () => {
const deps = makeDeps();
const sessions: any[] = [];
deps.adapter.listTargetConnectionIds = vi.fn().mockResolvedValue(['warehouse']);
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
sessions.push(toolSession);
return {
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
toRuntimeTools: vi.fn().mockReturnValue({}),
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
};
});
deps.agentRunner.runLoop.mockResolvedValue({ stopReason: 'natural' });
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/notion/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'notion',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect([...sessions[0].allowedConnectionNames].sort()).toEqual(['notion', 'warehouse']);
});
it('reuses document evidence indexing and page triage for document WorkUnits', async () => {
2026-05-10 23:12:26 +02:00
const deps = makeDeps();
deps.adapter.source = 'notion';
deps.adapter.skillNames = ['notion_synthesize'];
deps.adapter.reconcileSkillNames = [];
2026-05-10 23:12:26 +02:00
deps.adapter.evidenceIndexing = 'documents';
deps.adapter.triageSupported = true;
deps.adapter.chunk.mockResolvedValue({
workUnits: [
{ unitKey: 'full', rawFiles: ['pages/full/metadata.json'], dependencyPaths: [], peerFileIndex: [] },
{ unitKey: 'skip', rawFiles: ['pages/skip/metadata.json'], dependencyPaths: [], peerFileIndex: [] },
2026-05-10 23:12:26 +02:00
],
});
deps.diffSetService.compute.mockResolvedValue({
added: ['pages/full/metadata.json', 'pages/skip/metadata.json'],
2026-05-10 23:12:26 +02:00
modified: [],
deleted: [],
unchanged: [],
});
deps.pageTriage.triageRun.mockResolvedValue({
enabled: true,
fullRawPaths: new Set(['pages/full/metadata.json']),
2026-05-10 23:12:26 +02:00
warnings: [],
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([
['pages/full/metadata.json', 'h-full'],
['pages/skip/metadata.json', 'h-skip'],
2026-05-10 23:12:26 +02:00
]),
rawDirInWorktree: 'raw-sources/c1/notion/s',
2026-05-10 23:12:26 +02:00
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const result = await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'notion',
2026-05-10 23:12:26 +02:00
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
const workUnitCalls = deps.agentRunner.runLoop.mock.calls.filter(
([params]) => params.telemetryTags?.operationName === 'ingest-bundle-wu',
);
expect(deps.contextEvidenceIndex.indexStagedDir).toHaveBeenCalled();
expect(deps.pageTriage.triageRun).toHaveBeenCalled();
expect(workUnitCalls).toHaveLength(1);
expect(workUnitCalls[0][0].telemetryTags.unitKey).toBe('full');
expect(result.workUnitCount).toBe(1);
});
it('emits memory-flow source and planning events for bundle ingest', async () => {
const deps = makeDeps();
deps.adapter.chunk.mockResolvedValue({
workUnits: [
{
unitKey: 'u1',
rawFiles: ['a.yml'],
peerFileIndex: ['peer.yml'],
dependencyPaths: ['manifest.yml'],
},
],
eviction: { deletedRawPaths: ['old.yml'] },
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const snapshots: MemoryFlowReplayInput[] = [];
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput(), {
onChange: (snapshot) => snapshots.push(snapshot),
});
const ctx = new TestJobContext(
'j1',
null,
() => Promise.resolve(),
() => Promise.resolve(),
);
(ctx as any).memoryFlow = memoryFlow;
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
ctx,
);
expect(memoryFlow.snapshot()).toMatchObject({
runId: 'run-1',
connectionId: 'c1',
adapter: 'fake',
sourceDir: '/tmp/stage/upload-x',
});
expect(memoryFlow.snapshot().plannedWorkUnits).toEqual([
{
unitKey: 'u1',
rawFiles: ['a.yml'],
peerFileCount: 1,
dependencyCount: 1,
},
]);
expect(memoryFlow.snapshot().events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'source_acquired', adapter: 'fake', trigger: 'upload', fileCount: 1 }),
expect.objectContaining({ type: 'scope_detected', fingerprint: null }),
expect.objectContaining({ type: 'raw_snapshot_written', rawFileCount: 1 }),
expect.objectContaining({ type: 'diff_computed', added: 1, modified: 0, deleted: 0, unchanged: 0 }),
expect.objectContaining({ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 1 }),
]),
);
expect(snapshots.length).toBeGreaterThan(4);
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
memoryFlow: expect.objectContaining({
metadata: expect.objectContaining({
schemaVersion: 1,
mode: 'full',
origin: 'captured',
timing: 'captured',
}),
events: expect.arrayContaining([
expect.objectContaining({
type: 'source_acquired',
emittedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
}),
]),
}),
}),
}),
);
});
it('emits memory-flow WorkUnit step, candidate action, and finish events', async () => {
const deps = makeDeps();
let currentToolSession: any = null;
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
currentToolSession = toolSession;
return {
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
toRuntimeTools: vi.fn().mockReturnValue({}),
2026-05-10 23:12:26 +02:00
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
};
});
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
// A real tool call drives the live work_unit_step heartbeat.
await params.toolSet.record_verification_ledger.execute(
{ summary: 'Captured order context.', verifiedIdentifiers: [], unverifiedIdentifiers: [] },
{ toolCallId: 'ledger-1', messages: [] },
);
2026-05-10 23:12:26 +02:00
currentToolSession.actions.push({
target: 'wiki',
type: 'created',
key: 'wiki/orders.md',
2026-05-10 23:12:26 +02:00
detail: 'captured order context',
});
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput());
const ctx = new TestJobContext(
'j1',
null,
() => Promise.resolve(),
() => Promise.resolve(),
);
(ctx as any).memoryFlow = memoryFlow;
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
ctx,
);
expect(memoryFlow.snapshot().events).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'work_unit_started',
unitKey: 'u1',
skills: ['ingest_triage', 'sl_capture', 'wiki_capture'],
2026-05-10 23:12:26 +02:00
}),
expect.objectContaining({ type: 'work_unit_step', unitKey: 'u1', toolCalls: 1 }),
2026-05-10 23:12:26 +02:00
expect.objectContaining({
type: 'candidate_action',
unitKey: 'u1',
target: 'wiki',
action: 'created',
key: 'wiki/orders.md',
2026-05-10 23:12:26 +02:00
}),
expect.objectContaining({ type: 'work_unit_finished', unitKey: 'u1', status: 'success' }),
]),
);
});
it('emits memory-flow gate, saved, provenance, and report events', async () => {
const deps = makeDeps();
let currentToolSession: any = null;
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
currentToolSession = toolSession;
return {
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
toRuntimeTools: vi.fn().mockReturnValue({}),
2026-05-10 23:12:26 +02:00
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
};
});
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
currentToolSession.actions.push({
target: 'sl',
type: 'updated',
key: 'orders',
detail: 'captured gross revenue',
});
}
if (params.telemetryTags.operationName === 'ingest-bundle-reconcile') {
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
await params.toolSet.record_verification_ledger.execute(
{
summary: 'Reconciliation emits no warehouse identifiers before fallback recording.',
verifiedIdentifiers: [],
unverifiedIdentifiers: [],
},
{ toolCallId: 'ledger-1', messages: [] },
);
2026-05-10 23:12:26 +02:00
await params.toolSet.emit_conflict_resolution.execute(
{
kind: 'near_duplicate',
artifactKey: 'sl:orders',
detail: 'orders retained as canonical',
flaggedForHuman: false,
},
{ toolCallId: 'conflict-1', messages: [] },
);
await params.toolSet.emit_unmapped_fallback.execute(
{
rawPath: 'a.yml',
reason: 'parse_error',
clarification: 'semantic_not_representable',
2026-05-10 23:12:26 +02:00
fallback: 'flagged',
},
{ toolCallId: 'fallback-1', messages: [] },
);
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput());
const ctx = new TestJobContext(
'j1',
null,
() => Promise.resolve(),
() => Promise.resolve(),
);
(ctx as any).memoryFlow = memoryFlow;
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
ctx,
);
expect(memoryFlow.snapshot()).toMatchObject({
reportId: 'report-1',
reportPath: 'report-1',
});
expect(memoryFlow.snapshot().events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'reconciliation_finished', conflictCount: 1, fallbackCount: 1 }),
expect.objectContaining({ type: 'saved', commitSha: 'sq', wikiCount: 0, slCount: 1 }),
expect.objectContaining({ type: 'provenance_recorded', rowCount: 1 }),
expect.objectContaining({ type: 'report_created', runId: 'run-1', reportPath: 'report-1' }),
]),
);
});
it('finishes successful bundle memory-flow runs as done', async () => {
const deps = makeDeps();
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput());
const ctx = new TestJobContext(
'j1',
null,
() => Promise.resolve(),
() => Promise.resolve(),
);
(ctx as any).memoryFlow = memoryFlow;
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
ctx,
);
expect(memoryFlow.snapshot().status).toBe('done');
});
it('finishes bundle memory-flow runs with sanitized errors when the runner fails', async () => {
const deps = makeDeps();
const sensitiveMessage = [
'failed to read postgres://user',
':password',
'@localhost:5432/db?api_key=abc',
' token=',
'secret',
].join('');
deps.adapter.detect.mockRejectedValue(new Error(sensitiveMessage));
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput());
const ctx = new TestJobContext(
'j1',
null,
() => Promise.resolve(),
() => Promise.resolve(),
);
(ctx as any).memoryFlow = memoryFlow;
await expect(
runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
ctx,
),
).rejects.toThrow(/failed to read/);
expect(memoryFlow.snapshot()).toMatchObject({
status: 'error',
errors: ['failed to read postgres://[redacted] token=[redacted]'],
});
expect(memoryFlow.snapshot().events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'source_acquired', adapter: 'fake', trigger: 'upload', fileCount: 1 }),
]),
);
});
it('stores memory-flow provenance and transcript summaries in the ingest report body', async () => {
const deps = makeDeps();
deps.toolsetFactory.createIngestWuToolset.mockReturnValue({
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
toRuntimeTools: vi.fn().mockReturnValue({
2026-05-10 23:12:26 +02:00
read_raw_span: {
description: 'read a raw span',
inputSchema: {},
execute: vi.fn().mockResolvedValue('safe excerpt'),
},
wiki_write: {
description: 'write wiki',
inputSchema: {},
execute: vi.fn().mockResolvedValue('written'),
},
}),
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
});
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
await params.toolSet.read_raw_span.execute(
{ path: 'a.yml', startLine: 1, endLine: 2 },
{ toolCallId: 'read-1', messages: [] },
);
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
await params.toolSet.record_verification_ledger.execute(
{
summary: 'Wiki write contains no warehouse identifiers.',
verifiedIdentifiers: [],
unverifiedIdentifiers: [],
},
{ toolCallId: 'ledger-1', messages: [] },
);
2026-05-10 23:12:26 +02:00
await params.toolSet.wiki_write.execute(
{ key: 'wiki/a.md', content: 'safe summary' },
2026-05-10 23:12:26 +02:00
{ toolCallId: 'wiki-1', messages: [] },
);
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
provenanceRows: [
expect.objectContaining({
rawPath: 'a.yml',
artifactKind: null,
artifactKey: null,
actionType: 'skipped',
targetConnectionId: null,
}),
],
toolTranscripts: [
{
unitKey: 'u1',
2026-05-10 23:51:24 +02:00
path: '/tmp/ktx-test/run/wu-transcripts/j1/u1.jsonl',
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
toolCallCount: 3,
2026-05-10 23:12:26 +02:00
errorCount: 0,
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
toolNames: ['read_raw_span', 'record_verification_ledger', 'wiki_write'],
2026-05-10 23:12:26 +02:00
},
],
}),
}),
);
});
it('persists WorkUnit unmapped fallback records in the report body', async () => {
const deps = makeDeps();
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
await params.toolSet.record_verification_ledger.execute(
{
summary: 'Unmapped fallback records an unsupported conversion metric without verified warehouse identifiers.',
verifiedIdentifiers: [],
unverifiedIdentifiers: [],
},
{ toolCallId: 'ledger-1', messages: [] },
);
2026-05-10 23:12:26 +02:00
await params.toolSet.emit_unmapped_fallback.execute(
{
rawPath: 'a.yml',
reason: 'conversion_metric_unsupported',
fallback: 'flagged',
},
{ toolCallId: 'fallback-1', messages: [] },
);
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
unmappedFallbacks: [
{
rawPath: 'a.yml',
reason: 'conversion_metric_unsupported',
detail: expect.stringContaining('conversion metric'),
2026-05-10 23:12:26 +02:00
fallback: 'flagged',
},
],
}),
}),
);
});
it('persists reconciliation conflict and eviction records in the report body', async () => {
const deps = makeDeps();
deps.diffSetService.compute.mockResolvedValue({
added: [],
modified: [],
deleted: ['views/old_orders.view.lkml'],
unchanged: [],
});
deps.adapter.chunk.mockResolvedValue({
workUnits: [],
eviction: { deletedRawPaths: ['views/old_orders.view.lkml'] },
});
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-reconcile') {
feat(context): add warehouse verification tools (#46) * feat(context): add warehouse dialect dispatch * feat(context): read warehouse scan catalog * feat(context): add entity details verification tool * feat(context): add ingest SQL verification tool * feat(context): add raw warehouse discovery tool * feat(context): expose warehouse verification tools to ingest * docs(context): add ingest identifier verification protocol * test(context): guard ingest identifier verification prompts * chore(context): verify warehouse verification tools * docs: add warehouse verification tools plan and spec * fix(context): expose target warehouses to Notion ingest * fix(context): update ingest prompts for warehouse verification tools * fix(context): scope raw schema discovery to allowed connections * fix(context): verify warehouse column display targets * docs: add notion warehouse verification gap closure plan * fix(context): include raw discovery connection names * fix(context): expose warehouse targets for LookML and MetricFlow * fix(context): pass connection config to ingest query executors * fix(cli): enable read-only SQL probes for local ingest * docs: add warehouse verification final v1 closure plan * fix(context): align warehouse sql probe prompt shape * docs: add warehouse verification prompt shape closure plan * test(context): catch connectionless sql execution prompt examples * fix(context): include connection name in sl capture sql example * docs: add warehouse verification sql example closure plan * fix(context): report structured entity detail misses * docs: add warehouse verification structured target miss closure plan * fix: report untracked squash merge conflicts * feat: require ingest verification ledger * fix: stabilize ingest wiki references
2026-05-13 13:43:23 +02:00
await params.toolSet.record_verification_ledger.execute(
{
summary: 'Reconciliation records conflict, eviction, and fallback decisions without warehouse identifiers.',
verifiedIdentifiers: [],
unverifiedIdentifiers: [],
},
{ toolCallId: 'ledger-1', messages: [] },
);
2026-05-10 23:12:26 +02:00
await params.toolSet.emit_conflict_resolution.execute(
{
kind: 'near_duplicate',
artifactKey: 'sl:orders',
detail: 'orders and old_orders overlapped; orders is retained as canonical',
flaggedForHuman: false,
},
{ toolCallId: 'conflict-1', messages: [] },
);
await params.toolSet.emit_eviction_decision.execute(
{
rawPath: 'views/old_orders.view.lkml',
artifactKind: 'sl',
artifactKey: 'old_orders',
action: 'removed',
reason: 'raw source disappeared in this sync',
},
{ toolCallId: 'eviction-1', messages: [] },
);
await params.toolSet.emit_unmapped_fallback.execute(
{
rawPath: 'cards/untranslated.json',
reason: 'parse_error',
clarification: 'metabase_sql_untranslated',
2026-05-10 23:12:26 +02:00
fallback: 'flagged',
},
{ toolCallId: 'fallback-1', messages: [] },
);
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['cards/untranslated.json', 'h-card']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
conflictsResolved: [
{
kind: 'near_duplicate',
artifactKey: 'sl:orders',
detail: 'orders and old_orders overlapped; orders is retained as canonical',
flaggedForHuman: false,
},
],
evictionsApplied: [
{
rawPath: 'views/old_orders.view.lkml',
artifactKind: 'sl',
artifactKey: 'old_orders',
action: 'removed',
reason: 'raw source disappeared in this sync',
},
],
unmappedFallbacks: [
{
rawPath: 'cards/untranslated.json',
reason: 'parse_error',
detail: expect.stringContaining('metabase_sql_untranslated'),
2026-05-10 23:12:26 +02:00
fallback: 'flagged',
},
],
}),
}),
);
});
it('persists reconciliation artifact resolutions as provenance rows', async () => {
const deps = makeDeps();
deps.diffSetService.compute.mockResolvedValue({
added: [],
modified: [],
deleted: ['looks/20.json'],
unchanged: ['explores/b2b/sales_pipeline.json'],
});
deps.adapter.chunk.mockResolvedValue({
workUnits: [],
eviction: { deletedRawPaths: ['looks/20.json'] },
});
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-reconcile') {
await params.toolSet.emit_artifact_resolution.execute(
{
rawPath: 'explores/b2b/sales_pipeline.json',
artifactKind: 'sl',
artifactKey: 'looker__b2b__sales_pipeline',
actionType: 'subsumed',
reason: 'File adapter source b2b__sales_pipeline is canonical.',
},
{ toolCallId: 'resolution-1', messages: [] },
);
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['explores/b2b/sales_pipeline.json', 'h-explore']]),
rawDirInWorktree: 'raw-sources/c1/looker/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'looker',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(deps.provenanceRepo.insertMany).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
rawPath: 'explores/b2b/sales_pipeline.json',
artifactKind: 'sl',
artifactKey: 'looker__b2b__sales_pipeline',
actionType: 'subsumed',
}),
]),
);
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
artifactResolutions: [
{
rawPath: 'explores/b2b/sales_pipeline.json',
artifactKind: 'sl',
artifactKey: 'looker__b2b__sales_pipeline',
actionType: 'subsumed',
reason: 'File adapter source b2b__sales_pipeline is canonical.',
},
],
}),
}),
);
});
it('runs manual override reconciliation from the prior report snapshot and marks the prior report superseded', async () => {
2026-05-10 23:51:24 +02:00
const tempRoot = await mkdtemp(join(tmpdir(), 'ktx-override-'));
2026-05-10 23:12:26 +02:00
const deps = makeDeps();
deps.reportsRepo.findByJobId.mockResolvedValue({
id: 'report-old',
runId: 'run-old',
jobId: 'job-old',
connectionId: 'c1',
sourceKey: 'fake',
createdAt: '2026-04-27T10:00:00.000Z',
body: {
syncId: '2026-04-27-100000-job-old',
diffSummary: { added: 1, modified: 0, deleted: 0, unchanged: 0 },
commitSha: 'old-sha',
workUnits: [
{
unitKey: 'wu-orders',
rawFiles: ['a.yml'],
status: 'success',
actions: [
{
target: 'sl',
type: 'updated',
key: 'orders',
detail: 'captured gross_revenue as orders.gross_revenue',
},
],
touchedSlSources: ['orders'],
},
],
failedWorkUnits: [],
reconciliationSkipped: false,
conflictsResolved: [
{
kind: 'definitional_contradiction',
contestedKey: 'gross_revenue',
artifactKey: 'orders.gross_revenue',
detail: 'billing and orders disagree',
flaggedForHuman: true,
},
],
evictionsApplied: [],
unmappedFallbacks: [],
evictionInputs: [],
unresolvedCards: [],
supersededBy: null,
overrideOf: null,
},
});
deps.gitService.listFilesAtHead.mockResolvedValue(['raw-sources/c1/fake/2026-04-27-100000-job-old/a.yml']);
deps.gitService.getFileAtCommit.mockResolvedValue('name: orders\n');
deps.diffSetService.compute.mockResolvedValue({ added: [], modified: [], deleted: [], unchanged: ['a.yml'] });
deps.agentRunner.runLoop.mockImplementation(async (args: any) => {
await args.toolSet.emit_conflict_resolution.execute(
{
kind: 'definitional_contradiction',
contestedKey: 'gross_revenue',
artifactKey: 'orders.gross_revenue',
detail: 'canonical pin applied',
flaggedForHuman: false,
},
{ toolCallId: 'tc-1', messages: [] },
);
return { stopReason: 'natural' };
});
const runner = new IngestBundleRunner({
...(buildRunner(deps) as any).deps,
storage: {
homeDir: tempRoot,
systemGitAuthor: { name: 'ktx Test', email: 'system@ktx.local' },
2026-05-10 23:12:26 +02:00
resolveUploadDir: (uploadId: string) => join(tempRoot, 'ingest-uploads', uploadId),
resolvePullDir: (jobId: string) => join(tempRoot, 'ingest-pulls', jobId),
resolveTranscriptDir: (jobId: string) => join(tempRoot, 'run', 'wu-transcripts', jobId),
},
});
await runner.run({
jobId: 'job-new',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'manual_override',
bundleRef: { kind: 'override', priorJobId: 'job-old' },
});
await expect(readFile(join(tempRoot, 'ingest-pulls/job-new/a.yml'), 'utf-8')).resolves.toBe('name: orders\n');
expect(deps.adapter.chunk).not.toHaveBeenCalled();
expect(deps.agentRunner.runLoop).toHaveBeenCalled();
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'job-new',
body: expect.objectContaining({
overrideOf: 'job-old',
supersededBy: null,
conflictsResolved: [
expect.objectContaining({
contestedKey: 'gross_revenue',
flaggedForHuman: false,
}),
],
}),
}),
);
expect(deps.reportsRepo.markSuperseded).toHaveBeenCalledWith('job-old', 'job-new');
await rm(tempRoot, { recursive: true, force: true });
});
it('passes connection canonical pins into each WorkUnit system prompt', async () => {
const deps = makeDeps();
deps.adapter.chunk.mockResolvedValue({
workUnits: [
{
unitKey: 'wu-orders',
rawFiles: ['cards/orders.yml'],
peerFileIndex: [],
dependencyPaths: [],
},
],
});
deps.canonicalPins.listPins.mockResolvedValue([
{
contestedKey: 'gross_revenue',
canonicalArtifactKey: 'finance.gross_revenue',
pinnedAt: '2026-04-27T12:00:00.000Z',
pinnedBy: 'user-1',
reason: 'finance owns revenue definitions',
},
]);
deps.agentRunner.runLoop.mockResolvedValue({ stopReason: 'natural' });
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['cards/orders.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
const workUnitCall = deps.agentRunner.runLoop.mock.calls.find(
([params]: any[]) => params.telemetryTags.operationName === 'ingest-bundle-wu',
);
expect(workUnitCall?.[0].systemPrompt).toContain('<canonical_pins>');
expect(workUnitCall?.[0].systemPrompt).toContain('contestedKey: gross_revenue');
expect(workUnitCall?.[0].systemPrompt).toContain('canonicalArtifactKey: finance.gross_revenue');
expect(deps.canonicalPins.listPins).toHaveBeenCalledTimes(1);
expect(deps.canonicalPins.listPins).toHaveBeenCalledWith(['c1']);
});
it('builds WorkUnit SL index and canonical pins across adapter target connections', async () => {
const deps = makeDeps();
deps.adapter.listTargetConnectionIds = vi.fn().mockResolvedValue(['warehouse-2']);
deps.adapter.chunk.mockResolvedValue({
workUnits: [
{
unitKey: 'looker-explore-b2b-orders',
rawFiles: ['explores/b2b/orders.json'],
peerFileIndex: [],
dependencyPaths: [],
},
],
});
deps.canonicalPins.listPins.mockResolvedValue([
{
contestedKey: 'gross_revenue',
canonicalArtifactKey: 'finance.gross_revenue',
pinnedAt: '2026-05-01T12:00:00.000Z',
pinnedBy: 'user-1',
reason: 'finance owns revenue definitions',
},
]);
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['explores/b2b/orders.json', 'h1']]),
rawDirInWorktree: 'raw-sources/looker-run/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'looker-run',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
const workUnitCall = deps.agentRunner.runLoop.mock.calls.find(
([params]: any[]) => params.telemetryTags.operationName === 'ingest-bundle-wu',
);
expect(deps.adapter.listTargetConnectionIds).toHaveBeenCalledWith('/tmp/stage/upload-x');
expect(deps.semanticLayerService.loadAllSources).toHaveBeenCalledWith('looker-run');
expect(deps.semanticLayerService.loadAllSources).toHaveBeenCalledWith('warehouse-2');
2026-05-10 23:12:26 +02:00
expect(workUnitCall?.[0].userPrompt).toContain('looker__orders');
expect(deps.canonicalPins.listPins).toHaveBeenCalledWith(['looker-run', 'warehouse-2']);
});
it('syncs wiki refs, reindexes, and records provenance on SL target connections', async () => {
const deps = makeDeps();
let currentToolSession: any = null;
deps.adapter.listTargetConnectionIds = vi.fn().mockResolvedValue(['warehouse-2']);
deps.wikiService.readPage = vi.fn().mockResolvedValue({
frontmatter: { sl_refs: ['looker__b2b__sales_pipeline.arr'] },
});
deps.semanticLayerService.loadAllSources.mockImplementation((connectionId: string) =>
fix(context): merge overlay columns onto manifest columns by name (#94) * fix(context): merge overlay columns onto manifest columns by name composeOverlay was appending overlay columns to the manifest column list, producing duplicate entries when dbt/metabase overlays declared a column just to attach descriptions. The duplicates carried no `type`, so the pydantic SourceDefinition rejected them at semantic-query time and broke `ktx sl query` for every overlay-backed measure. Now overlay columns match base columns by name (case-insensitive): same-name entries merge onto the manifest (overlay fields win, type/role fall back to the base, descriptions merge per source key) and only new names append. * refactor(sl): split overlay columns from column_overrides and enforce TS/Python wire contract Overlay sources now have two distinct collections: `columns:` for computed columns (requiring `expr` + `type`) and `column_overrides:` for metadata patches to inherited manifest columns. Composing or loading an overlay that mixes the two — or references an unknown column — fails with a typed error. Introduce `ResolvedSemanticLayerSource` / `resolvedSourceSchema` / `toResolvedWire` as the strict shape sent to the Python engine, and add a schema contract test that diffs Zod against the Pydantic JSON schema dumped by `python -m semantic_layer dump-schema`. `SourceDefinition` is now `extra="forbid"` on the Python side. `loadAllSources` surfaces per-file load errors instead of swallowing them, so validation/query paths can report manifest shard parse failures. * fix(context): make scan description generation resilient and quiet A transient sampleTable failure during ingest used to take out every table in a connection: generateTableDescription returned a hardcoded 'Table not found' string into descriptions.ai, and KtxDescriptionGenerator was constructed without a logger, so the failure left no trail anywhere. - sampleTable / sampleColumn calls retry 3x with 200/400/800ms backoff, honouring KtxScanContext.signal via a new KtxAbortedError. - On retry exhaustion or missing capability, table generation falls back to a metadata-only prompt built from column name / native type / comment / rawDescriptions. The column path follows the same rule -- call the LLM when any of samples or rawDescriptions are available; skip only when both are absent. - Logger is now threaded from KtxScanContext into the generator. Failures emit structured KtxScanWarning entries (new description_fallback_used code, plus existing sampling_failed / enrichment_failed / connector_capability_missing). ktx scan groups warnings by code so a batch of identical failures collapses to one summary line plus sample. - Returns null on failure instead of the 'Table not found' sentinel; the manifest writer's existing guard already skips empty descriptions, so schema YAML no longer carries misleading text. SCAN_MANAGED_DESCRIPTION_KEYS already strips stale 'ai' on merge, so existing YAML clears on next run. Also suppress AI SDK v6 'system in messages' warning: pull system messages out of KtxMessageBuilder.wrapSimple's output via a new splitKtxSystemMessages helper and pass them top-level to generateText (preserves cacheControl providerOptions on the SystemModelMessage). Agent-runner's local splitSystemPromptMessages dedupes onto the shared helper. * test(docs): align examples-docs assertions with revamped docs PR #103 (setup/guide doc revamp) reworded several CLI examples and connection labels; the assertions in scripts/examples-docs.test.mjs still referenced the pre-revamp wording and were failing in CI on main. Update the regexes to match the post-revamp content: - drop the `--json` flag from the sl-query example expectation - move the `Driver:` / `Status: ok` probe to the connection reference, which is where that output now lives (driver id is lowercase `postgres`, not the display name `PostgreSQL`) - drop the obsolete `Install \`uv\`...` troubleshooting line - accept `<connectionId>` everywhere; the docs no longer use the hyphenated `<connection-id>` form - match the `warehouse` connection id used in the quickstart instead of the `postgres-warehouse` id only used in the README and setup ref * fix(sl): skip TS/Python schema contract test when uv is unavailable The TypeScript checks CI job does not install uv or Python, so the module-level `execFileSync('uv', ...)` in schemas.contract.test.ts threw ENOENT and failed the suite. Wrap the schema dump in a try/catch and guard the describe block with `describe.skipIf` so the test skips in environments without uv. Local dev and any CI job that has uv on PATH still runs the cross-language contract assertion.
2026-05-15 02:11:04 +02:00
Promise.resolve({ sources: [{ name: `${connectionId}_source` }], loadErrors: [] }),
2026-05-10 23:12:26 +02:00
);
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
currentToolSession.actions.push(
{
target: 'wiki',
type: 'created',
key: 'wiki/global/pipeline.md',
2026-05-10 23:12:26 +02:00
detail: 'Pipeline article',
},
{
target: 'sl',
type: 'created',
key: 'looker__b2b__sales_pipeline',
detail: 'Created warehouse source',
targetConnectionId: 'warehouse-2',
},
);
addTouchedSlSource(currentToolSession.touchedSlSources, 'warehouse-2', 'looker__b2b__sales_pipeline');
}
return { stopReason: 'natural' };
});
deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => {
currentToolSession = toolSession;
return {
feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos
2026-05-16 12:06:34 +02:00
toRuntimeTools: vi.fn().mockReturnValue({}),
2026-05-10 23:12:26 +02:00
getAllTools: vi.fn().mockReturnValue([]),
getToolNames: vi.fn().mockReturnValue([]),
};
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
currentHashes: new Map([['a.yml', 'h1']]),
2026-05-10 23:12:26 +02:00
rawDirInWorktree: 'raw-sources/looker-run/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'looker-run',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(deps.knowledgeSlRefs.syncFromWiki).toHaveBeenCalledWith({
wikiPageKey: 'wiki/global/pipeline.md',
2026-05-10 23:12:26 +02:00
wikiScope: 'GLOBAL',
wikiScopeId: null,
refs: [{ connectionId: 'warehouse-2', sourceName: 'looker__b2b__sales_pipeline' }],
});
expect(deps.semanticLayerService.loadAllSources).toHaveBeenCalledWith('warehouse-2');
expect(deps.slSearchService.indexSources).toHaveBeenCalledWith('warehouse-2', [{ name: 'warehouse-2_source' }]);
expect(deps.provenanceRepo.insertMany).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
connectionId: 'looker-run',
targetConnectionId: 'warehouse-2',
artifactKind: 'sl',
artifactKey: 'looker__b2b__sales_pipeline',
}),
expect.objectContaining({
connectionId: 'looker-run',
targetConnectionId: null,
artifactKind: 'wiki',
artifactKey: 'wiki/global/pipeline.md',
2026-05-10 23:12:26 +02:00
}),
]),
);
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
workUnits: [
expect.objectContaining({
touchedSlSources: [{ connectionId: 'warehouse-2', sourceName: 'looker__b2b__sales_pipeline' }],
}),
],
provenanceRows: expect.arrayContaining([
expect.objectContaining({
artifactKind: 'sl',
artifactKey: 'looker__b2b__sales_pipeline',
targetConnectionId: 'warehouse-2',
}),
]),
}),
}),
);
});
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
it('runs adapter finalization before squash, records the outcome, and reindexes touched sources', async () => {
2026-05-10 23:12:26 +02:00
const deps = makeDeps();
deps.adapter.source = 'metricflow';
deps.registry.get.mockReturnValue(deps.adapter);
deps.adapter.chunk.mockResolvedValue({
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
workUnits: [],
2026-05-10 23:12:26 +02:00
parseArtifacts: { semanticModels: [{ name: 'orders' }] },
});
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
deps.adapter.listTargetConnectionIds = vi.fn().mockResolvedValue(['warehouse-2']);
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
deps.adapter.finalize = vi.fn().mockResolvedValue({
result: { sourcesTouched: 1 },
warnings: ['kept going'],
errors: [],
touchedSources: [{ connectionId: 'warehouse-2', sourceName: 'orders' }],
changedWikiPageKeys: [],
actions: [
{
target: 'sl',
type: 'updated',
key: 'orders',
targetConnectionId: 'warehouse-2',
detail: 'Finalized orders usage',
rawPaths: ['semantic_models.yml'],
},
],
});
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
let head = 'pre-finalization';
// Touched-source derivation diffs composed sources before/after finalization
// (the filename never carries identity), so the mock must reflect the write:
// `orders` exists only once the finalization commit lands.
2026-05-10 23:12:26 +02:00
deps.semanticLayerService.loadAllSources.mockImplementation((connectionId: string) =>
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
Promise.resolve({
sources:
connectionId === 'warehouse-2' && head === 'post-finalization'
? [{ name: `${connectionId}_source` }, { name: 'orders' }]
: [{ name: `${connectionId}_source` }],
loadErrors: [],
}),
2026-05-10 23:12:26 +02:00
);
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
const git = {
revParseHead: vi.fn(async () => head),
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
// Touched-source derivation reads each changed file's `name:`; the worktree
// is mocked (no files on disk), so serve the source content from history.
getFileAtCommit: vi.fn(async () => 'name: orders\n'),
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
commitFiles: vi.fn().mockImplementation(async (paths: string[]) => {
if (paths.includes('semantic-layer/warehouse-2/orders.yaml')) {
head = 'post-finalization';
return { created: true, commitHash: 'finalization-sha' };
}
return { created: true, commitHash: head };
}),
commitStaged: vi.fn().mockResolvedValue({ created: false, commitHash: 'post-finalization' }),
resetHardTo: vi.fn(),
assertWorktreeClean: vi.fn().mockResolvedValue(undefined),
writeBinaryNoRenamePatch: vi.fn(async (_base: string, _head: string, patchPath: string) => {
await writeFile(patchPath, '', 'utf-8');
2026-05-10 23:12:26 +02:00
}),
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
applyPatchFile3WayIndex: vi.fn(),
diffNameStatus: vi.fn().mockImplementation(async (from: string, to: string) =>
from === 'pre-finalization' && to === 'post-finalization'
? [{ status: 'M', path: 'semantic-layer/warehouse-2/orders.yaml' }]
: [],
),
changedPaths: vi.fn().mockResolvedValue(['semantic-layer/warehouse-2/orders.yaml']),
2026-05-10 23:12:26 +02:00
};
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
deps.sessionWorktreeService.create.mockResolvedValue({
chatId: 'j1',
workdir: '/tmp/wt',
branch: 'session/j1',
baseSha: 'b',
createdAt: new Date(),
git,
config: {},
});
const runner = buildRunner(deps);
2026-05-10 23:12:26 +02:00
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['semantic_models.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/metricflow/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'metricflow',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
expect(deps.adapter.finalize).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'c1',
sourceKey: 'metricflow',
syncId: expect.any(String),
jobId: 'j1',
runId: 'run-1',
workdir: '/tmp/wt',
parseArtifacts: { semanticModels: [{ name: 'orders' }] },
}),
);
2026-05-10 23:12:26 +02:00
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
finalization: expect.objectContaining({
2026-05-10 23:12:26 +02:00
sourceKey: 'metricflow',
status: 'success',
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
commitSha: 'finalization-sha',
touchedPaths: ['semantic-layer/warehouse-2/orders.yaml'],
derivedTouchedSources: [{ connectionId: 'warehouse-2', sourceName: 'orders' }],
declaredTouchedSources: [{ connectionId: 'warehouse-2', sourceName: 'orders' }],
actions: [expect.objectContaining({ key: 'orders' })],
}),
2026-05-10 23:12:26 +02:00
}),
}),
);
expect(deps.semanticLayerService.loadAllSources).toHaveBeenCalledWith('warehouse-2');
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
expect(deps.slSearchService.indexSources).toHaveBeenCalledWith('warehouse-2', [
{ name: 'warehouse-2_source' },
{ name: 'orders' },
]);
2026-05-10 23:12:26 +02:00
expect(deps.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'success');
});
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
it('recovers a deleted hash-named SL source by its in-file name, not its filename', async () => {
const runner = buildRunner();
// An uppercase warehouse source lives in a hash-derived filename, so parsing
// the basename yields the phantom `widget_sales-1a2b3c4d`. The real name must
// come from the file's `name:`, recovered from history once it was deleted.
const deletedPath = 'semantic-layer/warehouse/widget_sales-1a2b3c4d.yaml';
const getFileAtCommit = vi.fn(async () => 'name: WIDGET_SALES\ntable: WIDGET_SALES\n');
const worktree = { workdir: join(tmpdir(), 'ktx-absent-worktree-recover'), git: { getFileAtCommit } };
const touched = await (runner as any).touchedSlSourcesFromPaths(worktree, [deletedPath], 'pre-change-sha');
expect(touched).toEqual([{ connectionId: 'warehouse', sourceName: 'WIDGET_SALES' }]);
expect(getFileAtCommit).toHaveBeenCalledWith(deletedPath, 'pre-change-sha');
});
it('falls back to the filename only when a deleted SL file is unrecoverable from history', async () => {
const runner = buildRunner();
const deletedPath = 'semantic-layer/warehouse/orders.yaml';
const getFileAtCommit = vi.fn(async () => {
throw new Error('path not present at commit');
});
const worktree = { workdir: join(tmpdir(), 'ktx-absent-worktree-fallback'), git: { getFileAtCommit } };
const touched = await (runner as any).touchedSlSourcesFromPaths(worktree, [deletedPath], 'pre-change-sha');
expect(touched).toEqual([{ connectionId: 'warehouse', sourceName: 'orders' }]);
});
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
it('includes finalization actions in memory-flow saved counts', async () => {
const deps = makeDeps();
deps.adapter.source = 'historic-sql';
deps.registry.get.mockReturnValue(deps.adapter);
deps.adapter.chunk.mockResolvedValue({
workUnits: [
{
unitKey: 'historic-sql-table-public-orders',
rawFiles: ['tables/public/orders.json'],
peerFileIndex: [],
dependencyPaths: [],
},
],
});
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
deps.adapter.finalize = vi.fn().mockResolvedValue({
warnings: [],
errors: [],
touchedSources: [],
changedWikiPageKeys: [],
actions: [
{ target: 'sl', type: 'updated', key: 'orders', detail: 'Merged usage' },
{ target: 'sl', type: 'updated', key: 'customers', detail: 'Merged usage' },
{ target: 'wiki', type: 'created', key: 'historic-sql-orders', detail: 'Projected pattern' },
{ target: 'wiki', type: 'updated', key: 'historic-sql-customers', detail: 'Projected pattern' },
],
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['tables/public/orders.json', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/historic-sql/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const memoryFlow = createMemoryFlowLiveBuffer(bundleReplayInput());
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'historic-sql',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
{
jobId: 'j1',
memoryFlow,
startPhase: () => new TestJobContext('j1', null, () => Promise.resolve(), () => Promise.resolve()),
},
);
expect(memoryFlow.snapshot().events).toContainEqual(
expect.objectContaining({
type: 'saved',
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
wikiCount: 2,
slCount: 2,
}),
);
});
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
it('marks finalization infrastructure failure as failed and preserves worktree cleanup state', async () => {
2026-05-10 23:12:26 +02:00
const deps = makeDeps();
deps.adapter.source = 'metricflow';
deps.registry.get.mockReturnValue(deps.adapter);
deps.adapter.chunk.mockResolvedValue({
workUnits: [{ unitKey: 'u1', rawFiles: ['semantic_models.yml'], peerFileIndex: [], dependencyPaths: [] }],
parseArtifacts: { semanticModels: [{ name: 'orders' }] },
});
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
deps.adapter.finalize = vi.fn().mockRejectedValue(new Error('worktree write failed'));
const runner = buildRunner(deps);
2026-05-10 23:12:26 +02:00
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['semantic_models.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/metricflow/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await expect(
runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'metricflow',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
}),
).rejects.toThrow('worktree write failed');
expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1');
expect(deps.gitService.squashMergeIntoMain).not.toHaveBeenCalled();
expect(deps.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'crash');
});
feat(ingest): adapter-owned finalization replaces post-processor escape hatch (#136) * Refine adapter-owned ingest finalization design after adversarial review iteration 1 * Refine adapter-owned ingest finalization design after adversarial review iteration 2 * Refine adapter-owned ingest finalization design after adversarial review iteration 3 * Implement adapter-owned ingest finalization v1 Moves finalization from runner-owned post-processors into typed SourceAdapter.finalize() contracts. Adds finalization report schema, scope derivation, override replay context, and migrates historic-SQL projection. Removes IngestBundlePostProcessorPort wiring and HistoricSqlProjectionPostProcessor. * feat(ingest): export finalization adapter contract types * test(ingest): exercise historic sql finalization locally * docs(plans): add adapter-owned finalization v1 closure plan * fix(setup): unblock clean Linux installs and add enabled_tables allowlist - Pin managed Python runtime to 3.13 via `uv venv --python 3.13` so installs don't pick the system 3.12 on Ubuntu 24.04 and fail at wheel install. - Sanitize NO_PROXY/no_proxy for the daemon child process — drop IPv6 CIDR entries that httpx rejects with InvalidURL (OrbStack injects these by default). - Add `enabled_tables` allowlist on warehouse connections (zod schema + live-database introspection filter) to scope ingest to specific tables. - Add `getting-started/troubleshooting-linux` docs page covering the Python 3.13 prerequisite, IPv6 proxy gotcha, and a minimal working recipe; link it from the quickstart troubleshooting table and the llms-docs map. - Make docs-site origin overridable via `KTX_DOCS_ORIGIN` so local builds can serve under host.docker.internal. * Move docs changes to specs repo * fix(cli): keep managed runtime python version private * Deduplicate enabled tables filtering
2026-05-20 14:17:10 +02:00
it('reports finalization actions excluded from provenance when raw paths are not defensible', async () => {
const deps = makeDeps();
deps.adapter.finalize = vi.fn().mockResolvedValue({
warnings: [],
errors: [],
touchedSources: [],
changedWikiPageKeys: [],
actions: [
{ target: 'wiki', type: 'updated', key: 'historic-sql-pattern', detail: 'No raw path' },
{ target: 'sl', type: 'updated', key: 'orders', detail: 'Invalid raw path', rawPaths: ['missing.json'] },
],
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['current.json', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
expect(deps.reportsRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
finalization: expect.objectContaining({
provenanceExclusions: [
expect.objectContaining({ reason: 'missing_raw_paths' }),
expect.objectContaining({ reason: 'raw_path_not_defensible', invalidRawPaths: ['missing.json'] }),
],
}),
}),
}),
);
expect(deps.provenanceRepo.insertMany).not.toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ rawPath: 'missing.json' })]),
);
});
it('passes explicit override replay metadata and no current work unit outcomes', async () => {
const deps = makeDeps();
deps.reportsRepo.findByJobId.mockResolvedValue({
id: 'prior-report',
runId: 'prior-run',
jobId: 'prior-job',
connectionId: 'c1',
sourceKey: 'fake',
createdAt: '2026-05-18T00:00:00.000Z',
body: {
status: 'completed',
syncId: 'prior-sync',
diffSummary: { added: 0, modified: 0, deleted: 0, unchanged: 0 },
commitSha: 'prior-sha',
workUnits: [
{
unitKey: 'prior-unit',
rawFiles: ['prior.json'],
status: 'success',
actions: [{ target: 'wiki', type: 'created', key: 'prior', detail: 'prior' }],
touchedSlSources: [],
},
],
failedWorkUnits: [],
reconciliationSkipped: false,
conflictsResolved: [],
evictionsApplied: [
{
rawPath: 'do-not-replay.json',
artifactKind: 'wiki',
artifactKey: 'old',
action: 'removed',
reason: 'prior',
},
],
unmappedFallbacks: [],
artifactResolutions: [],
evictionInputs: ['evicted-from-prior-report.json'],
unresolvedCards: [],
supersededBy: null,
overrideOf: null,
provenanceRows: [],
toolTranscripts: [],
},
});
deps.adapter.finalize = vi.fn().mockResolvedValue({
warnings: [],
errors: [],
touchedSources: [],
changedWikiPageKeys: [],
actions: [],
});
deps.gitService.listFilesAtHead.mockResolvedValue(['raw-sources/c1/fake/prior-sync/prior.json']);
deps.gitService.getFileAtCommit.mockResolvedValue('{"id":1}\n');
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['prior.json', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/prior-sync',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/prior');
await runner.run({
jobId: 'override-job',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'manual_override',
bundleRef: { kind: 'override', priorJobId: 'prior-job' },
});
expect(deps.adapter.finalize).toHaveBeenCalledWith(
expect.objectContaining({
workUnitOutcomes: [],
overrideReplay: {
priorJobId: 'prior-job',
priorRunId: 'prior-run',
priorSyncId: 'prior-sync',
evictionRawPaths: ['evicted-from-prior-report.json'],
},
}),
);
});
2026-05-10 23:12:26 +02:00
it('includes existing global wiki pages in WorkUnit prompts', async () => {
const deps = makeDeps();
deps.knowledgeIndex.listPagesForUser.mockResolvedValue([
{
page_key: 'revenue-recognition',
summary: 'Recognize revenue net of refunds after fulfillment.',
scope: 'GLOBAL',
scope_id: null,
},
]);
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['cards/orders.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
const workUnitCall = deps.agentRunner.runLoop.mock.calls.find(
([params]: any[]) => params.telemetryTags.operationName === 'ingest-bundle-wu',
);
expect(workUnitCall?.[0].userPrompt).toContain('## Wiki Pages');
2026-05-10 23:12:26 +02:00
expect(workUnitCall?.[0].userPrompt).toContain(
'- revenue-recognition: Recognize revenue net of refunds after fulfillment.',
);
expect(deps.knowledgeIndex.listPagesForUser).toHaveBeenCalledWith('system');
});
it('includes manifest-backed target sources in WorkUnit prompts', async () => {
const deps = makeDeps();
deps.adapter.listTargetConnectionIds = vi.fn().mockResolvedValue(['postgres-warehouse']);
deps.semanticLayerService.loadAllSources.mockImplementation((connectionId: string) =>
fix(context): merge overlay columns onto manifest columns by name (#94) * fix(context): merge overlay columns onto manifest columns by name composeOverlay was appending overlay columns to the manifest column list, producing duplicate entries when dbt/metabase overlays declared a column just to attach descriptions. The duplicates carried no `type`, so the pydantic SourceDefinition rejected them at semantic-query time and broke `ktx sl query` for every overlay-backed measure. Now overlay columns match base columns by name (case-insensitive): same-name entries merge onto the manifest (overlay fields win, type/role fall back to the base, descriptions merge per source key) and only new names append. * refactor(sl): split overlay columns from column_overrides and enforce TS/Python wire contract Overlay sources now have two distinct collections: `columns:` for computed columns (requiring `expr` + `type`) and `column_overrides:` for metadata patches to inherited manifest columns. Composing or loading an overlay that mixes the two — or references an unknown column — fails with a typed error. Introduce `ResolvedSemanticLayerSource` / `resolvedSourceSchema` / `toResolvedWire` as the strict shape sent to the Python engine, and add a schema contract test that diffs Zod against the Pydantic JSON schema dumped by `python -m semantic_layer dump-schema`. `SourceDefinition` is now `extra="forbid"` on the Python side. `loadAllSources` surfaces per-file load errors instead of swallowing them, so validation/query paths can report manifest shard parse failures. * fix(context): make scan description generation resilient and quiet A transient sampleTable failure during ingest used to take out every table in a connection: generateTableDescription returned a hardcoded 'Table not found' string into descriptions.ai, and KtxDescriptionGenerator was constructed without a logger, so the failure left no trail anywhere. - sampleTable / sampleColumn calls retry 3x with 200/400/800ms backoff, honouring KtxScanContext.signal via a new KtxAbortedError. - On retry exhaustion or missing capability, table generation falls back to a metadata-only prompt built from column name / native type / comment / rawDescriptions. The column path follows the same rule -- call the LLM when any of samples or rawDescriptions are available; skip only when both are absent. - Logger is now threaded from KtxScanContext into the generator. Failures emit structured KtxScanWarning entries (new description_fallback_used code, plus existing sampling_failed / enrichment_failed / connector_capability_missing). ktx scan groups warnings by code so a batch of identical failures collapses to one summary line plus sample. - Returns null on failure instead of the 'Table not found' sentinel; the manifest writer's existing guard already skips empty descriptions, so schema YAML no longer carries misleading text. SCAN_MANAGED_DESCRIPTION_KEYS already strips stale 'ai' on merge, so existing YAML clears on next run. Also suppress AI SDK v6 'system in messages' warning: pull system messages out of KtxMessageBuilder.wrapSimple's output via a new splitKtxSystemMessages helper and pass them top-level to generateText (preserves cacheControl providerOptions on the SystemModelMessage). Agent-runner's local splitSystemPromptMessages dedupes onto the shared helper. * test(docs): align examples-docs assertions with revamped docs PR #103 (setup/guide doc revamp) reworded several CLI examples and connection labels; the assertions in scripts/examples-docs.test.mjs still referenced the pre-revamp wording and were failing in CI on main. Update the regexes to match the post-revamp content: - drop the `--json` flag from the sl-query example expectation - move the `Driver:` / `Status: ok` probe to the connection reference, which is where that output now lives (driver id is lowercase `postgres`, not the display name `PostgreSQL`) - drop the obsolete `Install \`uv\`...` troubleshooting line - accept `<connectionId>` everywhere; the docs no longer use the hyphenated `<connection-id>` form - match the `warehouse` connection id used in the quickstart instead of the `postgres-warehouse` id only used in the README and setup ref * fix(sl): skip TS/Python schema contract test when uv is unavailable The TypeScript checks CI job does not install uv or Python, so the module-level `execFileSync('uv', ...)` in schemas.contract.test.ts threw ENOENT and failed the suite. Wrap the schema dump in a try/catch and guard the describe block with `describe.skipIf` so the test skips in environments without uv. Local dev and any CI job that has uv on PATH still runs the cross-language contract assertion.
2026-05-15 02:11:04 +02:00
Promise.resolve({
sources: connectionId === 'postgres-warehouse' ? [{ name: 'stg_accounts' }] : [],
loadErrors: [],
}),
);
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['models/schema.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/dbt-main/dbt/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'dbt-main',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
const workUnitCall = deps.agentRunner.runLoop.mock.calls.find(
([params]: any[]) => params.telemetryTags.operationName === 'ingest-bundle-wu',
);
expect(workUnitCall?.[0].userPrompt).toContain('## postgres-warehouse');
expect(workUnitCall?.[0].userPrompt).toContain('stg_accounts');
expect(deps.canonicalPins.listPins).toHaveBeenCalledWith(['dbt-main', 'postgres-warehouse']);
});
it('does not resolve qualified fallback table refs by source name alone', async () => {
const deps = makeDeps();
fix(context): merge overlay columns onto manifest columns by name (#94) * fix(context): merge overlay columns onto manifest columns by name composeOverlay was appending overlay columns to the manifest column list, producing duplicate entries when dbt/metabase overlays declared a column just to attach descriptions. The duplicates carried no `type`, so the pydantic SourceDefinition rejected them at semantic-query time and broke `ktx sl query` for every overlay-backed measure. Now overlay columns match base columns by name (case-insensitive): same-name entries merge onto the manifest (overlay fields win, type/role fall back to the base, descriptions merge per source key) and only new names append. * refactor(sl): split overlay columns from column_overrides and enforce TS/Python wire contract Overlay sources now have two distinct collections: `columns:` for computed columns (requiring `expr` + `type`) and `column_overrides:` for metadata patches to inherited manifest columns. Composing or loading an overlay that mixes the two — or references an unknown column — fails with a typed error. Introduce `ResolvedSemanticLayerSource` / `resolvedSourceSchema` / `toResolvedWire` as the strict shape sent to the Python engine, and add a schema contract test that diffs Zod against the Pydantic JSON schema dumped by `python -m semantic_layer dump-schema`. `SourceDefinition` is now `extra="forbid"` on the Python side. `loadAllSources` surfaces per-file load errors instead of swallowing them, so validation/query paths can report manifest shard parse failures. * fix(context): make scan description generation resilient and quiet A transient sampleTable failure during ingest used to take out every table in a connection: generateTableDescription returned a hardcoded 'Table not found' string into descriptions.ai, and KtxDescriptionGenerator was constructed without a logger, so the failure left no trail anywhere. - sampleTable / sampleColumn calls retry 3x with 200/400/800ms backoff, honouring KtxScanContext.signal via a new KtxAbortedError. - On retry exhaustion or missing capability, table generation falls back to a metadata-only prompt built from column name / native type / comment / rawDescriptions. The column path follows the same rule -- call the LLM when any of samples or rawDescriptions are available; skip only when both are absent. - Logger is now threaded from KtxScanContext into the generator. Failures emit structured KtxScanWarning entries (new description_fallback_used code, plus existing sampling_failed / enrichment_failed / connector_capability_missing). ktx scan groups warnings by code so a batch of identical failures collapses to one summary line plus sample. - Returns null on failure instead of the 'Table not found' sentinel; the manifest writer's existing guard already skips empty descriptions, so schema YAML no longer carries misleading text. SCAN_MANAGED_DESCRIPTION_KEYS already strips stale 'ai' on merge, so existing YAML clears on next run. Also suppress AI SDK v6 'system in messages' warning: pull system messages out of KtxMessageBuilder.wrapSimple's output via a new splitKtxSystemMessages helper and pass them top-level to generateText (preserves cacheControl providerOptions on the SystemModelMessage). Agent-runner's local splitSystemPromptMessages dedupes onto the shared helper. * test(docs): align examples-docs assertions with revamped docs PR #103 (setup/guide doc revamp) reworded several CLI examples and connection labels; the assertions in scripts/examples-docs.test.mjs still referenced the pre-revamp wording and were failing in CI on main. Update the regexes to match the post-revamp content: - drop the `--json` flag from the sl-query example expectation - move the `Driver:` / `Status: ok` probe to the connection reference, which is where that output now lives (driver id is lowercase `postgres`, not the display name `PostgreSQL`) - drop the obsolete `Install \`uv\`...` troubleshooting line - accept `<connectionId>` everywhere; the docs no longer use the hyphenated `<connection-id>` form - match the `warehouse` connection id used in the quickstart instead of the `postgres-warehouse` id only used in the README and setup ref * fix(sl): skip TS/Python schema contract test when uv is unavailable The TypeScript checks CI job does not install uv or Python, so the module-level `execFileSync('uv', ...)` in schemas.contract.test.ts threw ENOENT and failed the suite. Wrap the schema dump in a try/catch and guard the describe block with `describe.skipIf` so the test skips in environments without uv. Local dev and any CI job that has uv on PATH still runs the cross-language contract assertion.
2026-05-15 02:11:04 +02:00
deps.semanticLayerService.loadAllSources.mockResolvedValue({
sources: [{ name: 'orders', table: 'sales.orders' }],
loadErrors: [],
});
const runner = buildRunner(deps);
await expect(
(runner as any).tableRefExistsInSemanticLayer(deps.semanticLayerService, ['warehouse'], 'finance.orders'),
).resolves.toBe(false);
await expect(
(runner as any).tableRefExistsInSemanticLayer(deps.semanticLayerService, ['warehouse'], 'sales.orders'),
).resolves.toBe(true);
});
2026-05-10 23:12:26 +02:00
it('passes relevant canonical pins into the reconciliation system prompt', async () => {
const deps = makeDeps();
deps.diffSetService.compute.mockResolvedValue({
added: [],
modified: [],
deleted: ['metrics/old.yml'],
unchanged: [],
});
deps.adapter.chunk.mockResolvedValue({
workUnits: [
{
unitKey: 'wu-billing',
rawFiles: ['metrics/churn_risk_score.yml'],
peerFileIndex: [],
dependencyPaths: [],
},
],
eviction: { deletedRawPaths: ['metrics/old.yml'] },
});
deps.canonicalPins.listPins.mockResolvedValue([
{
contestedKey: 'churn_risk_score',
canonicalArtifactKey: 'billing.churn_risk_score',
pinnedAt: '2026-04-27T12:00:00.000Z',
pinnedBy: 'user-1',
reason: 'billing owns the contractual definition',
},
{
contestedKey: 'gross_margin',
canonicalArtifactKey: 'finance.gross_margin',
pinnedAt: '2026-04-27T12:01:00.000Z',
pinnedBy: 'user-2',
reason: null,
},
]);
deps.agentRunner.runLoop.mockImplementation(async (params: any) => {
if (params.telemetryTags.operationName === 'ingest-bundle-wu') {
return { stopReason: 'natural' };
}
return { stopReason: 'natural' };
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([
['metrics/churn_risk_score.yml', 'h1'],
['metrics/old.yml', 'h2'],
]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
});
const reconcileCall = deps.agentRunner.runLoop.mock.calls.find(
([params]: any[]) => params.telemetryTags.operationName === 'ingest-bundle-reconcile',
);
expect(reconcileCall?.[0].systemPrompt).toContain('<canonical_pins>');
expect(reconcileCall?.[0].systemPrompt).toContain('contestedKey: churn_risk_score');
expect(reconcileCall?.[0].systemPrompt).not.toContain('gross_margin');
expect(deps.canonicalPins.listPins).toHaveBeenCalledWith(['c1']);
});
it('emits a monotonically non-decreasing progress sequence reaching 1.0, covering all 7 stages', async () => {
const deps = makeDeps();
deps.agentRunner.runLoop.mockImplementation(async () => ({ stopReason: 'natural' }));
2026-05-10 23:12:26 +02:00
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
const observed: Array<{ p: number; m?: string }> = [];
const ctx = new TestJobContext(
'j1',
null,
() => Promise.resolve(),
(p, m) => {
observed.push({ p, m });
return Promise.resolve();
},
);
await runner.run(
{
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
},
ctx,
);
// Monotonic.
for (let i = 1; i < observed.length; i++) {
expect(observed[i].p).toBeGreaterThanOrEqual(observed[i - 1].p);
}
// Reaches completion.
expect(observed.at(-1)?.p).toBeCloseTo(1.0, 3);
// Every stage surfaces a user-facing message.
const phaseLabels = [
'Fetching source files',
'Planning updates',
'Processing',
/Reconcil|reconcil/,
'Saving changes',
'Recording history',
'Wrapping up',
];
for (const label of phaseLabels) {
expect(observed.some((o) => (typeof label === 'string' ? o.m?.includes(label) : label.test(o.m ?? '')))).toBe(
true,
);
}
});
it('a Stage 3 failure leaves the shared knowledge table untouched', async () => {
const deps = makeDeps();
// Agent runner returns a successful result but the adapter emits a WU whose
// outcome still produces no actions — the point is that the scoped wiki service
// must not touch indexRepository during Stage 3, and syncFromCommit is what
// drives the shared table. If we cancel the run before squash, syncFromCommit
// must not be called.
deps.gitService.squashMergeIntoMain.mockRejectedValue(new Error('simulated squash failure'));
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await expect(
runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
}),
).rejects.toThrow(/simulated squash failure/);
expect(deps.wikiService.syncFromCommit).not.toHaveBeenCalled();
});
it('refuses to squash-merge when the session worktree has an in-progress sequencer op', async () => {
const deps = makeDeps();
const assertError = new Error('Worktree has in-progress git operation (sequencer ...); refusing to proceed');
const sessionGit = {
revParseHead: vi.fn().mockResolvedValue('h'),
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
commitFiles: vi.fn().mockResolvedValue({ created: true, commitHash: 'h' }),
commitStaged: vi.fn().mockResolvedValue({ created: false, commitHash: 'h' }),
2026-05-10 23:12:26 +02:00
resetHardTo: vi.fn(),
assertWorktreeClean: vi.fn().mockRejectedValue(assertError),
feat(ingest): default local ingest to isolated diffs (#128) * docs: add isolated-diff ingestion design * Refine isolated-diff ingestion design after adversarial review iteration 1 * Refine isolated-diff ingestion design after adversarial review iteration 2 * Refine isolated-diff ingestion design after adversarial review iteration 3 * feat: persist ingest trace events * feat: add isolated ingest patch helpers * feat: validate wiki body semantic references * feat: add final ingest artifact gates * feat: execute ingest work units in child worktrees * feat: integrate isolated work unit patches * feat: route selected ingest sources through isolated diffs * test: cover isolated diff ingestion regressions * feat: add isolated diff ingestion v1 core * docs: document ingest trace inspection * docs: add isolated diff ingestion v1 core plan * fix(ingest): tighten final artifact gates * fix(ingest): gate isolated final integration tree * fix(ingest): persist postmortem failure traces * fix(ingest): trace policy conflicts and cleanup child worktrees * test(ingest): verify isolated diff postmortem coverage * docs: add isolated diff ingestion gates and trace closure plan * fix(ingest): gate provenance before isolated diff squash * docs: add isolated diff ingestion provenance gate closure plan * fix(ingest): gate final wiki references * fix(ingest): enforce SL target connection scope * fix(ingest): trace isolated SL target policy gates * test(ingest): cover isolated diff reference and target gates * chore(ingest): verify isolated diff gate closure * docs: add isolated diff ingestion reference and target gate closure plan * fix(ingest): gate global wiki references * docs: add isolated diff ingestion global wiki reference gate closure plan * fix(ingest): validate scan sources and wiki refs * test(ingest): cover isolated diff textual conflict resolver * test(ingest): cover isolated diff resolver integration * feat(ingest): repair isolated diff textual conflicts * feat(ingest): report isolated diff resolver outcomes * test(ingest): verify isolated diff textual conflict repair * test(ingest): align textual conflict failure coverage * docs: add isolated diff textual conflict resolver plan * test(ingest): cover isolated diff gate repair * feat(ingest): add isolated diff gate repair agent * feat(ingest): repair isolated diff semantic gate failures * feat(ingest): wire isolated diff gate repair * test(ingest): verify isolated diff final gate repair * chore(ingest): verify isolated diff gate repair * docs: add isolated diff gate repair plan * Improve ingest progress updates * feat(ingest): route direct-write connectors through isolated diffs * test(ingest): cover non-metabase isolated diff routing * feat(ingest): project metricflow semantic models before work units * test(ingest): verify metricflow isolated projection path * chore(ingest): verify isolated diff connector migration * docs: add isolated diff connector migration plan * feat(ingest): make isolated diff routing the private default * feat(ingest): promote isolated diff to default runner path * feat(ingest): default local ingest to isolated diffs * chore(ingest): remove isolated diff allowlist references * fix(ingest): preserve transient evidence for isolated work units * docs: add isolated diff default promotion plan * refactor(ingest): remove shared worktree WorkUnit path * docs(ingest): align WorkUnit prompts with isolated diffs * test(ingest): drop unused runner import * docs: add isolated diff shared worktree removal plan * docs: add isolated diff gate repair classification plan * fix: restrict claude-code mcp servers * docs: align ingest trace guidance with public CLI --------- Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
2026-05-18 13:38:06 +02:00
writeBinaryNoRenamePatch: vi.fn(async (_base: string, _head: string, patchPath: string) => {
await writeFile(patchPath, '', 'utf-8');
}),
applyPatchFile3WayIndex: vi.fn(),
diffNameStatus: vi.fn().mockResolvedValue([]),
2026-05-10 23:12:26 +02:00
};
deps.sessionWorktreeService.create.mockResolvedValue({
chatId: 'j1',
workdir: '/tmp/wt',
branch: 'session/j1',
baseSha: 'b',
createdAt: new Date(),
git: sessionGit,
config: {},
});
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await expect(
runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
}),
).rejects.toThrow(/in-progress git operation/);
expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1');
expect(deps.gitService.squashMergeIntoMain).not.toHaveBeenCalled();
});
it('fails the run and rethrows when the adapter cannot detect the bundle', async () => {
const deps = makeDeps();
deps.adapter.detect.mockResolvedValue(false);
const runner = buildRunner(deps);
(runner as any).stageRawFilesStage1 = vi.fn().mockResolvedValue({
currentHashes: new Map([['a.yml', 'h1']]),
rawDirInWorktree: 'raw-sources/c1/fake/s',
});
(runner as any).resolveStagedDir = vi.fn().mockResolvedValue('/tmp/stage/upload-x');
await expect(
runner.run({
jobId: 'j1',
connectionId: 'c1',
sourceKey: 'fake',
trigger: 'upload',
bundleRef: { kind: 'upload', uploadId: 'upload-x' },
}),
).rejects.toThrow(/did not recognize/);
expect(deps.runsRepo.markFailed).toHaveBeenCalledWith('run-1');
});
});