@duckcodeailabs/dql-cli 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,19 +4,19 @@ import { createServer } from "node:http";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, watch, writeFileSync, } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
6
  import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { dirname, extname, join, normalize, relative, resolve, } from "node:path";
8
9
  import { Readable } from "node:stream";
9
10
  import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, planMixedSourceNotebookSql, planMixedSourceSql, } from "./mixed-source-sql.js";
10
- import Anthropic from "@anthropic-ai/sdk";
11
- import OpenAI from "openai";
11
+ import { resolveNpmInvocation } from './npm-runtime.js';
12
12
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
13
- import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, relationshipValidationProofFingerprint, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, } from '@duckcodeailabs/dql-core';
13
+ import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, relationshipValidationProofFingerprint, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, } from '@duckcodeailabs/dql-core';
14
14
  import { load as loadYaml } from 'js-yaml';
15
15
  import { listBlockTemplates } from './block-templates.js';
16
16
  import { getRunner as getLLMRunner } from './llm/index.js';
17
17
  import { createDqlAgentProviderRunner } from './llm/providers/dql-agent-provider.js';
18
18
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
19
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, synthesizeAnswer, streamOrGenerate, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, resolveDomainContextEnvelope, defaultKgPath, planApp, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, probeLocalOllamaEmbeddings, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
19
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, probeLocalOllamaEmbeddings, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
20
20
  import { gatherProposeEnrichment } from './propose-enrich.js';
21
21
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
22
22
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -351,6 +351,21 @@ function compactConversationRecord(record) {
351
351
  function apiRequestId(scope) {
352
352
  return `${scope}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
353
353
  }
354
+ /** UI-007 / E2E-005: report the installed CLI version that owns this runtime. */
355
+ export function readDqlRuntimeVersion(runtimeUrl = import.meta.url) {
356
+ const runtimeDir = dirname(fileURLToPath(runtimeUrl));
357
+ for (const packagePath of [join(runtimeDir, 'package.json'), join(runtimeDir, '../package.json')]) {
358
+ try {
359
+ const parsed = JSON.parse(readFileSync(packagePath, 'utf-8'));
360
+ if (typeof parsed.version === 'string' && parsed.version.trim())
361
+ return parsed.version.trim();
362
+ }
363
+ catch {
364
+ // Source and published layouts place package.json at different levels.
365
+ }
366
+ }
367
+ return 'unknown';
368
+ }
354
369
  function apiErrorEnvelope(input) {
355
370
  return {
356
371
  requestId: input.requestId,
@@ -377,6 +392,7 @@ export async function startLocalServer(opts) {
377
392
  const bindHost = opts.host ?? process.env.DQL_HOST ?? '127.0.0.1';
378
393
  const loopback = bindHost === '127.0.0.1' || bindHost === 'localhost' || bindHost === '::1';
379
394
  const authToken = opts.authToken ?? process.env.DQL_SERVER_TOKEN;
395
+ const runtimeVersion = readDqlRuntimeVersion();
380
396
  const allowedOrigins = new Set((opts.allowedOrigins ?? (process.env.DQL_ALLOWED_ORIGINS ?? '').split(','))
381
397
  .map((value) => value.trim().replace(/\/$/, ''))
382
398
  .filter(Boolean));
@@ -389,13 +405,19 @@ export async function startLocalServer(opts) {
389
405
  const gitRoot = await resolveGitRoot(projectRoot);
390
406
  if (gitRoot)
391
407
  ensureLocalRuntimeGitignore(projectRoot);
392
- let connection = rawConnection ? normalizeProjectConnection(rawConnection, projectRoot) : null;
393
408
  let projectConfig = loadProjectConfig(projectRoot);
409
+ const configuredConnection = rawConnection
410
+ ? normalizeProjectConnection(rawConnection, projectRoot)
411
+ : projectConfig.defaultConnection
412
+ ? normalizeProjectConnection(projectConfig.defaultConnection, projectRoot)
413
+ : null;
414
+ let connection = configuredConnection ?? resolveDbtProfileRuntimeConnection(projectRoot, projectConfig);
394
415
  const projectSnapshots = new ProjectSnapshotService();
416
+ const dashboardRunEvidence = new Map();
395
417
  const dbtNodeDetailCache = new Map();
396
418
  const onboardingJobs = new Map();
397
419
  const projectSnapshot = () => {
398
- const dbtManifestPath = resolveDbtManifestPath(projectRoot) ?? undefined;
420
+ const dbtManifestPath = resolveDbtManifestPath(projectRoot, projectConfig) ?? undefined;
399
421
  const inputs = collectInputFiles({ projectRoot, dbtManifestPath });
400
422
  const signatureHash = createHash('sha256');
401
423
  for (const input of inputs.sort()) {
@@ -421,14 +443,26 @@ export async function startLocalServer(opts) {
421
443
  };
422
444
  };
423
445
  const onboardingDbtPaths = (body = {}) => {
424
- const projectDirInput = typeof body.projectDir === 'string' ? body.projectDir : projectConfig.dbt?.projectDir ?? '.';
446
+ const repoUrl = typeof body.repoUrl === 'string' && body.repoUrl.trim() ? body.repoUrl.trim() : undefined;
447
+ const branch = typeof body.branch === 'string' && body.branch.trim() ? body.branch.trim() : undefined;
448
+ const subPath = typeof body.subPath === 'string' && body.subPath.trim() ? body.subPath.trim() : undefined;
449
+ const repo = repoUrl ? resolveRepoSource({
450
+ provider: 'dbt', source: repoUrl.includes('gitlab') ? 'gitlab' : 'github', repoUrl, branch, subPath,
451
+ }, projectRoot) : undefined;
452
+ const projectDirInput = repo?.localPath ?? (typeof body.projectDir === 'string' ? body.projectDir : projectConfig.dbt?.projectDir ?? '.');
425
453
  const dbtProjectDir = resolve(projectRoot, projectDirInput);
426
454
  const manifestInput = typeof body.manifestPath === 'string' ? body.manifestPath : projectConfig.dbt?.manifestPath ?? 'target/manifest.json';
427
455
  const manifestPath = resolve(dbtProjectDir, manifestInput);
428
456
  if (!(manifestPath === dbtProjectDir || manifestPath.startsWith(`${dbtProjectDir}/`))) {
429
457
  throw Object.assign(new Error('dbt manifestPath must stay inside dbt projectDir.'), { code: 'DBT_ARTIFACT_INVALID' });
430
458
  }
431
- return { dbtProjectDir, manifestPath, projectDirInput, manifestInput };
459
+ const profilesDirInput = typeof body.profilesDir === 'string'
460
+ ? body.profilesDir
461
+ : projectConfig.dbt?.profilesDir;
462
+ const configuredProfilesDir = profilesDirInput ? resolve(projectRoot, profilesDirInput) : undefined;
463
+ const profilePath = findDbtProfilePaths(projectRoot, dbtProjectDir, configuredProfilesDir)[0];
464
+ const profilesDir = configuredProfilesDir ?? (profilePath ? dirname(profilePath) : undefined);
465
+ return { dbtProjectDir, manifestPath, projectDirInput, manifestInput, profilesDir, repoUrl, branch, subPath, repoWarnings: repo?.warnings ?? [] };
432
466
  };
433
467
  const previewDbtOnboarding = (body = {}) => {
434
468
  const paths = onboardingDbtPaths(body);
@@ -445,8 +479,11 @@ export async function startLocalServer(opts) {
445
479
  }
446
480
  const fingerprint = createHash('sha256').update(readFileSync(paths.manifestPath)).digest('hex');
447
481
  return {
448
- projectDir: relative(projectRoot, paths.dbtProjectDir).replace(/\\/g, '/') || '.',
482
+ projectDir: portableProjectPath(projectRoot, paths.dbtProjectDir),
449
483
  manifestPath: relative(paths.dbtProjectDir, paths.manifestPath).replace(/\\/g, '/'),
484
+ ...(paths.profilesDir ? { profilesDir: portableProjectPath(projectRoot, paths.profilesDir) } : {}),
485
+ ...(paths.repoUrl ? { repoUrl: paths.repoUrl, branch: paths.branch, subPath: paths.subPath } : {}),
486
+ ...(paths.repoWarnings.length ? { warnings: paths.repoWarnings } : {}),
450
487
  fingerprint,
451
488
  projectName: typeof raw.metadata?.project_name === 'string' ? raw.metadata.project_name : undefined,
452
489
  counts: {
@@ -1900,17 +1937,19 @@ export async function startLocalServer(opts) {
1900
1937
  // A real app needs a certified anchor: only offer confirmation when at least
1901
1938
  // one certified tile is present. Otherwise fall through to the escalate path
1902
1939
  // (research / draft the missing blocks) so the user isn't sent to a dead end.
1903
- const proposed = Boolean(proposal && proposal.tiles.some((tile) => tile.certification === 'certified' && !tile.error));
1940
+ const proposed = Boolean(proposal && proposal.tiles.some((tile) => (tile.certification === 'certified' || tile.certification === 'reviewed_semantic') && !tile.error));
1904
1941
  const sessionPlan = agentRunRecord(session.plan);
1905
1942
  const appTitle = agentRunString(sessionPlan?.name) ?? 'App proposal';
1906
1943
  const certifiedCount = proposal?.coverage.certifiedTiles ?? 0;
1907
1944
  const generatedCount = proposal?.coverage.generatedTiles ?? 0;
1945
+ const semanticCount = proposal?.coverage.semanticTiles ?? 0;
1908
1946
  const gapCount = proposal?.coverage.gaps ?? 0;
1909
1947
  // A coverage gap is NOT terminal — leave status open so the gate can escalate to
1910
1948
  // drafting the missing blocks. Only genuine infra errors (the catch above) block.
1911
1949
  return {
1912
1950
  summary: proposed
1913
1951
  ? `Proposed ${certifiedCount} certified tile${certifiedCount === 1 ? '' : 's'}`
1952
+ + (semanticCount > 0 ? ` + ${semanticCount} reviewed semantic tile${semanticCount === 1 ? '' : 's'}` : '')
1914
1953
  + (generatedCount > 0 ? ` + ${generatedCount} AI-generated (review required)` : '')
1915
1954
  + (gapCount > 0 ? `; ${gapCount} question${gapCount === 1 ? '' : 's'} still uncovered` : '')
1916
1955
  + '. Review the list and confirm to create the app.'
@@ -1924,6 +1963,7 @@ export async function startLocalServer(opts) {
1924
1963
  dashboardId: session.dashboardId,
1925
1964
  plan: session.plan,
1926
1965
  proposal,
1966
+ proposalHash: session.proposalHash,
1927
1967
  warnings: session.warnings,
1928
1968
  }, session.appId)] : [],
1929
1969
  evaluations: [
@@ -3385,13 +3425,44 @@ export async function startLocalServer(opts) {
3385
3425
  }
3386
3426
  if (req.method === 'GET' && path === '/api/health') {
3387
3427
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3388
- res.end(serializeJSON({ status: 'ok' }));
3428
+ res.end(serializeJSON({ status: 'ok', version: runtimeVersion }));
3429
+ return;
3430
+ }
3431
+ if (req.method === 'GET' && path === '/api/onboarding/launch') {
3432
+ const requestId = apiRequestId('onboarding-launch');
3433
+ const acknowledgedVersion = readUserPrefs(userPrefsPath).setup?.acknowledgedVersion ?? null;
3434
+ const shouldOpen = acknowledgedVersion !== runtimeVersion;
3435
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3436
+ res.end(serializeJSON({
3437
+ requestId,
3438
+ version: runtimeVersion,
3439
+ acknowledgedVersion,
3440
+ shouldOpen,
3441
+ reason: shouldOpen ? (acknowledgedVersion ? 'version_upgrade' : 'first_install') : null,
3442
+ }));
3443
+ return;
3444
+ }
3445
+ if (req.method === 'POST' && path === '/api/onboarding/acknowledge') {
3446
+ const requestId = apiRequestId('onboarding-acknowledge');
3447
+ const prefs = readUserPrefs(userPrefsPath);
3448
+ prefs.setup = {
3449
+ acknowledgedVersion: runtimeVersion,
3450
+ acknowledgedAt: new Date().toISOString(),
3451
+ };
3452
+ writeUserPrefs(userPrefsPath, prefs);
3453
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3454
+ res.end(serializeJSON({ requestId, version: runtimeVersion, acknowledged: true }));
3389
3455
  return;
3390
3456
  }
3391
3457
  if (req.method === 'GET' && path === '/api/onboarding/status') {
3392
3458
  const requestId = apiRequestId('onboarding-status');
3393
- const dbtProjectDir = resolve(projectRoot, projectConfig.dbt?.projectDir ?? '.');
3459
+ const dbtProjectDir = findDbtProjectPath(projectRoot, projectConfig);
3394
3460
  const manifestPath = resolve(dbtProjectDir, projectConfig.dbt?.manifestPath ?? 'target/manifest.json');
3461
+ const configuredDbtSource = projectConfig.dbt?.repoUrl ?? projectConfig.dbt?.projectDir;
3462
+ const dbtConfigured = existsSync(join(dbtProjectDir, 'dbt_project.yml'))
3463
+ || Boolean(configuredDbtSource && !/\{\{[^}]+\}\}/.test(configuredDbtSource));
3464
+ const configuredProfilesDir = projectConfig.dbt?.profilesDir ? resolve(projectRoot, projectConfig.dbt.profilesDir) : undefined;
3465
+ const profilePath = findDbtProfilePaths(projectRoot, dbtProjectDir, configuredProfilesDir)[0];
3395
3466
  const registry = loadDomainPackageRegistry(projectRoot);
3396
3467
  const manifestFound = existsSync(manifestPath);
3397
3468
  let snapshotId;
@@ -3409,9 +3480,15 @@ export async function startLocalServer(opts) {
3409
3480
  requestId,
3410
3481
  snapshotId,
3411
3482
  dbt: {
3412
- configured: Boolean(projectConfig.dbt),
3483
+ configured: dbtConfigured,
3413
3484
  projectDir: projectConfig.dbt?.projectDir,
3414
3485
  manifestPath: projectConfig.dbt?.manifestPath,
3486
+ profilesDir: configuredProfilesDir
3487
+ ? portableProjectPath(projectRoot, configuredProfilesDir)
3488
+ : profilePath ? portableProjectPath(projectRoot, dirname(profilePath)) : undefined,
3489
+ repoUrl: projectConfig.dbt?.repoUrl,
3490
+ branch: projectConfig.dbt?.branch,
3491
+ subPath: projectConfig.dbt?.subPath,
3415
3492
  projectFound: existsSync(join(dbtProjectDir, 'dbt_project.yml')),
3416
3493
  manifestFound,
3417
3494
  },
@@ -3457,7 +3534,9 @@ export async function startLocalServer(opts) {
3457
3534
  throw error;
3458
3535
  const { dbtProjectDir } = onboardingDbtPaths(body);
3459
3536
  try {
3460
- execFileSync('dbt', ['parse', '--project-dir', dbtProjectDir], {
3537
+ const { profilesDir } = onboardingDbtPaths(body);
3538
+ const parseArgs = buildDbtParseArgs(dbtProjectDir, profilesDir);
3539
+ execFileSync('dbt', parseArgs, {
3461
3540
  cwd: dbtProjectDir,
3462
3541
  timeout: 120_000,
3463
3542
  maxBuffer: 1024 * 1024,
@@ -3483,16 +3562,24 @@ export async function startLocalServer(opts) {
3483
3562
  ...projectConfig,
3484
3563
  manifestVersion: 3,
3485
3564
  modeling: { mode: 'dbt-first' },
3486
- dbt: { projectDir: preview.projectDir, manifestPath: preview.manifestPath },
3565
+ dbt: {
3566
+ projectDir: preview.projectDir,
3567
+ manifestPath: preview.manifestPath,
3568
+ ...(preview.profilesDir ? { profilesDir: preview.profilesDir } : {}),
3569
+ ...(preview.repoUrl ? { repoUrl: preview.repoUrl, branch: preview.branch, subPath: preview.subPath } : {}),
3570
+ },
3487
3571
  semanticLayer: { provider: 'dbt', projectPath: preview.projectDir },
3488
3572
  };
3489
3573
  const configPath = join(projectRoot, 'dql.config.json');
3490
3574
  const previousConfigSource = readFileSync(configPath, 'utf8');
3491
3575
  const previousProjectConfig = projectConfig;
3576
+ const previousConnection = connection;
3492
3577
  const tempPath = `${configPath}.tmp-${process.pid}`;
3493
3578
  writeFileSync(tempPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
3494
3579
  renameSync(tempPath, configPath);
3495
3580
  projectConfig = nextConfig;
3581
+ if (!configuredConnection)
3582
+ connection = resolveDbtProfileRuntimeConnection(projectRoot, nextConfig);
3496
3583
  projectSnapshots.invalidate();
3497
3584
  invalidateAgentProjectState(projectRoot);
3498
3585
  let snapshotId;
@@ -3504,6 +3591,7 @@ export async function startLocalServer(opts) {
3504
3591
  writeFileSync(rollbackPath, previousConfigSource, { encoding: 'utf8', mode: 0o600 });
3505
3592
  renameSync(rollbackPath, configPath);
3506
3593
  projectConfig = previousProjectConfig;
3594
+ connection = previousConnection;
3507
3595
  projectSnapshots.invalidate();
3508
3596
  invalidateAgentProjectState(projectRoot);
3509
3597
  throw Object.assign(new Error(`dbt-first configuration did not compile and was rolled back: ${error instanceof Error ? error.message : String(error)}`), { code: 'SNAPSHOT_BUILD_FAILED' });
@@ -3722,11 +3810,64 @@ export async function startLocalServer(opts) {
3722
3810
  // provenance references and the sparse DQL overlay, never copied dbt YAML.
3723
3811
  if (req.method === 'GET' && path === '/api/modeling/dbt-first') {
3724
3812
  const requestId = apiRequestId('modeling-dbt-first');
3725
- const snapshot = projectSnapshot();
3813
+ const modelingEnabled = projectConfig.manifestVersion === 3 && projectConfig.modeling?.mode === 'dbt-first';
3814
+ if (!modelingEnabled) {
3815
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
3816
+ res.end(serializeJSON(apiErrorEnvelope({
3817
+ requestId,
3818
+ code: 'DBT_FIRST_NOT_ENABLED',
3819
+ message: 'dbt-first modeling is not enabled. Connect and apply a dbt project from Settings → Project & dbt.',
3820
+ details: {
3821
+ manifestVersion: projectConfig.manifestVersion ?? 2,
3822
+ modelingMode: projectConfig.modeling?.mode ?? null,
3823
+ },
3824
+ nextActions: ['Open Settings → Project & dbt and apply a valid dbt project.'],
3825
+ })));
3826
+ return;
3827
+ }
3828
+ const dbtProjectDir = findDbtProjectPath(projectRoot, projectConfig);
3829
+ const configuredManifestPath = projectConfig.dbt?.manifestPath ?? 'target/manifest.json';
3830
+ const expectedManifestPath = resolve(dbtProjectDir, configuredManifestPath);
3831
+ if (!existsSync(expectedManifestPath)) {
3832
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
3833
+ res.end(serializeJSON(apiErrorEnvelope({
3834
+ requestId,
3835
+ code: 'DBT_MANIFEST_NOT_FOUND',
3836
+ message: `The configured dbt manifest was not found at ${configuredManifestPath}.`,
3837
+ details: {
3838
+ projectDir: projectConfig.dbt?.projectDir ?? portableProjectPath(projectRoot, dbtProjectDir),
3839
+ manifestPath: configuredManifestPath,
3840
+ },
3841
+ nextActions: ['Run dbt parse, dbt compile, or dbt build in the configured dbt project, then refresh Domain Studio.'],
3842
+ })));
3843
+ return;
3844
+ }
3845
+ let snapshot;
3846
+ try {
3847
+ snapshot = projectSnapshot();
3848
+ }
3849
+ catch (error) {
3850
+ res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
3851
+ res.end(serializeJSON(apiErrorEnvelope({
3852
+ requestId,
3853
+ code: 'DBT_MANIFEST_COMPILE_FAILED',
3854
+ message: apiErrorMessage(error),
3855
+ details: { manifestPath: configuredManifestPath },
3856
+ nextActions: ['Fix or rebuild the dbt manifest, then refresh Domain Studio.'],
3857
+ })));
3858
+ return;
3859
+ }
3726
3860
  const manifest = snapshot.manifest;
3727
3861
  if (manifest.manifestVersion !== 3 || !manifest.modeling || !manifest.dbtProvenance) {
3728
- res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
3729
- res.end(serializeJSON(apiErrorEnvelope({ requestId, snapshotId: snapshot.snapshotId, code: 'DBT_FIRST_NOT_ENABLED', message: 'dbt-first modeling is not enabled. Set manifestVersion: 3 and modeling.mode: "dbt-first" in dql.config.json.', nextActions: ['Use Setup to connect dbt and enable dbt-first modeling.'] })));
3862
+ res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
3863
+ res.end(serializeJSON(apiErrorEnvelope({
3864
+ requestId,
3865
+ snapshotId: snapshot.snapshotId,
3866
+ code: 'DBT_MANIFEST_COMPILE_FAILED',
3867
+ message: snapshot.error ?? 'DQL could not compile Domain Studio from the configured dbt manifest.',
3868
+ details: { manifestPath: configuredManifestPath },
3869
+ nextActions: ['Review the dbt artifact and DQL compile diagnostics, rebuild the dbt manifest, then refresh Domain Studio.'],
3870
+ })));
3730
3871
  return;
3731
3872
  }
3732
3873
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -4446,6 +4587,52 @@ export async function startLocalServer(opts) {
4446
4587
  // ── Skills (spec 16) — user-authored business context. AI drafts, humans
4447
4588
  // certify; skills never carry certification. PROJECT skills (user empty)
4448
4589
  // are shared; PERSONAL skills (user set) are user-bound. ────────────────
4590
+ if (req.method === 'GET' && path === '/api/skills/settings') {
4591
+ try {
4592
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
4593
+ res.end(serializeJSON(buildSkillPathSettings(projectRoot)));
4594
+ }
4595
+ catch (error) {
4596
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
4597
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
4598
+ }
4599
+ return;
4600
+ }
4601
+ if (req.method === 'PUT' && path === '/api/skills/settings') {
4602
+ try {
4603
+ const body = (await readJSON(req).catch(() => ({})));
4604
+ const requestedPath = typeof body.path === 'string' ? body.path.trim() : '';
4605
+ if (!requestedPath || requestedPath.includes('\0')) {
4606
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
4607
+ res.end(serializeJSON({ error: 'Provide an existing Skills folder path.' }));
4608
+ return;
4609
+ }
4610
+ const resolvedPath = resolve(projectRoot, requestedPath);
4611
+ if (!existsSync(resolvedPath) || !statSync(resolvedPath).isDirectory()) {
4612
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
4613
+ res.end(serializeJSON({ error: `Skills folder not found: ${resolvedPath}` }));
4614
+ return;
4615
+ }
4616
+ const configPath = join(projectRoot, 'dql.config.json');
4617
+ const raw = existsSync(configPath)
4618
+ ? JSON.parse(readFileSync(configPath, 'utf-8'))
4619
+ : {};
4620
+ const layout = raw.layout && typeof raw.layout === 'object' && !Array.isArray(raw.layout)
4621
+ ? raw.layout
4622
+ : {};
4623
+ raw.layout = { ...layout, skillsPath: requestedPath };
4624
+ writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf-8');
4625
+ projectConfig = loadProjectConfig(projectRoot);
4626
+ invalidateAgentProjectState(projectRoot);
4627
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
4628
+ res.end(serializeJSON(buildSkillPathSettings(projectRoot)));
4629
+ }
4630
+ catch (error) {
4631
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
4632
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
4633
+ }
4634
+ return;
4635
+ }
4449
4636
  if (req.method === 'GET' && path === '/api/skills') {
4450
4637
  try {
4451
4638
  const skills = loadSkills(projectRoot).skills
@@ -5148,6 +5335,33 @@ export async function startLocalServer(opts) {
5148
5335
  }
5149
5336
  return;
5150
5337
  }
5338
+ const appStoryRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/story$/);
5339
+ if (req.method === 'POST' && appStoryRun) {
5340
+ const body = await readJSON(req).catch(() => ({}));
5341
+ const runId = typeof body.runId === 'string' ? body.runId : '';
5342
+ const evidence = dashboardRunEvidence.get(runId);
5343
+ const appId = decodeURIComponent(appStoryRun[1]);
5344
+ const dashboardId = decodeURIComponent(appStoryRun[2]);
5345
+ if (!evidence || evidence.expiresAt < Date.now() || evidence.appId !== appId || evidence.dashboardId !== dashboardId) {
5346
+ dashboardRunEvidence.delete(runId);
5347
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
5348
+ res.end(serializeJSON({ error: 'Dashboard run evidence expired or does not match this App. Run the dashboard again.' }));
5349
+ return;
5350
+ }
5351
+ // The endpoint accepts runId only. Optional provider wording can be added
5352
+ // here later; the claim-checked deterministic brief is always available.
5353
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
5354
+ res.end(serializeJSON({
5355
+ runId,
5356
+ snapshotId: evidence.snapshotId,
5357
+ filterFingerprint: evidence.filterFingerprint,
5358
+ resultFingerprint: evidence.resultFingerprint,
5359
+ personaFingerprint: evidence.personaFingerprint,
5360
+ facts: evidence.facts,
5361
+ story: evidence.story,
5362
+ }));
5363
+ return;
5364
+ }
5151
5365
  const appDashRun = path.match(/^\/api\/apps\/([^/]+)\/dashboards\/([^/]+)\/run$/);
5152
5366
  if (req.method === 'POST' && appDashRun) {
5153
5367
  try {
@@ -5160,6 +5374,12 @@ export async function startLocalServer(opts) {
5160
5374
  res.end(serializeJSON({ error: `Dashboard "${dashboardId}" not found in app "${appId}"` }));
5161
5375
  return;
5162
5376
  }
5377
+ const activeApp = activePersonaAppId();
5378
+ if (activeApp && activeApp !== appId) {
5379
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
5380
+ res.end(serializeJSON({ error: `The active persona belongs to App "${activeApp}", not "${appId}".` }));
5381
+ return;
5382
+ }
5163
5383
  const manifest = buildManifest({ projectRoot });
5164
5384
  const variables = body.variables && typeof body.variables === 'object'
5165
5385
  ? body.variables
@@ -5258,6 +5478,70 @@ export async function startLocalServer(opts) {
5258
5478
  });
5259
5479
  continue;
5260
5480
  }
5481
+ if (item.semantic) {
5482
+ try {
5483
+ if (!semanticLayer)
5484
+ throw new Error('Semantic layer is not available for this governed query.');
5485
+ const knownMetrics = new Set(semanticLayer.listMetrics().map((metric) => metric.name));
5486
+ const missingMetrics = item.semantic.metrics.filter((metric) => !knownMetrics.has(metric));
5487
+ if (missingMetrics.length)
5488
+ throw new Error(`Semantic definition changed; missing metric(s): ${missingMetrics.join(', ')}`);
5489
+ const knownModels = new Set(semanticLayer.listSemanticModels().map((model) => model.name));
5490
+ const missingModels = item.semantic.semanticModelRefs.filter((model) => !knownModels.has(model));
5491
+ if (missingModels.length)
5492
+ throw new Error(`Semantic definition changed; missing model(s): ${missingModels.join(', ')}`);
5493
+ const targetConnection = requireActiveConnection(isConnectionConfig(body.connection) ? body.connection : connection);
5494
+ const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer);
5495
+ const activeFilters = Object.entries(dashboardVariables)
5496
+ .filter(([field, value]) => item.semantic.dimensions?.includes(field) && value !== undefined && value !== null && value !== '')
5497
+ .map(([dimension, value]) => ({ dimension, operator: 'equals', values: Array.isArray(value) ? value.map(String) : [String(value)] }));
5498
+ const staticFilters = (item.semantic.filters ?? []).map((filter) => ({
5499
+ dimension: filter.field,
5500
+ operator: filter.operator,
5501
+ values: Array.isArray(filter.value) ? filter.value.map(String) : [String(filter.value)],
5502
+ }));
5503
+ const composed = composeRuntimeSemanticQuery({
5504
+ metrics: item.semantic.metrics,
5505
+ dimensions: item.semantic.dimensions ?? [],
5506
+ filters: [...staticFilters, ...activeFilters],
5507
+ ...(item.semantic.timeDimension ? { timeDimension: { name: item.semantic.timeDimension, granularity: 'month' } } : {}),
5508
+ orderBy: item.semantic.orderBy?.map((order) => ({ name: order.field, direction: order.direction })),
5509
+ limit: item.semantic.limit,
5510
+ engine: item.semantic.provider,
5511
+ }, semanticLayer, {
5512
+ projectRoot,
5513
+ projectConfig,
5514
+ detectedProvider: semanticDetectedProvider,
5515
+ driver: targetConnection.driver,
5516
+ tableMapping,
5517
+ });
5518
+ if (!composed)
5519
+ throw new Error('The governed semantic query could not be composed.');
5520
+ const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
5521
+ const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
5522
+ tiles.push({
5523
+ tileId: item.i,
5524
+ status: 'ok',
5525
+ tileType: 'semantic',
5526
+ title: item.title ?? item.semantic.id,
5527
+ viz: item.viz,
5528
+ result: normalizeQueryResult(result),
5529
+ trustState: 'review_required',
5530
+ citation: { kind: 'semantic_query', name: item.semantic.id },
5531
+ });
5532
+ }
5533
+ catch (err) {
5534
+ tiles.push({
5535
+ tileId: item.i,
5536
+ status: 'error',
5537
+ tileType: 'semantic',
5538
+ title: item.title ?? item.semantic.id,
5539
+ error: err instanceof Error ? err.message : String(err),
5540
+ trustState: 'review_required',
5541
+ });
5542
+ }
5543
+ continue;
5544
+ }
5261
5545
  const block = resolveDashboardItemBlock(item, manifest);
5262
5546
  if (!block) {
5263
5547
  tiles.push({
@@ -5381,12 +5665,55 @@ export async function startLocalServer(opts) {
5381
5665
  }
5382
5666
  }
5383
5667
  localApps?.close();
5668
+ const snapshot = projectSnapshot();
5669
+ const filterFingerprint = createHash('sha256').update(JSON.stringify(dashboardVariables)).digest('hex');
5670
+ const resultFingerprint = createHash('sha256').update(JSON.stringify(tiles.map((tile) => ({ tileId: tile.tileId, status: tile.status, result: tile.result })))).digest('hex');
5671
+ const personaFingerprint = createHash('sha256').update(activePersonaAppId() ?? 'global').digest('hex');
5672
+ const runId = `app_run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
5673
+ const storyResult = buildDeterministicDashboardStory({
5674
+ goal: loaded.dashboard.story?.goal ?? loaded.dashboard.metadata.businessOutcome ?? loaded.dashboard.metadata.title,
5675
+ audience: loaded.dashboard.story?.audience ?? loaded.dashboard.metadata.audience,
5676
+ filters: dashboardVariables,
5677
+ tiles: tiles.map((tile) => ({
5678
+ tileId: tile.tileId,
5679
+ title: typeof tile.title === 'string' ? tile.title : tile.tileId,
5680
+ status: tile.status,
5681
+ trustState: (tile.trustState === 'certified' || tile.trustState === 'review_required' || tile.trustState === 'draft_ready'
5682
+ ? tile.trustState
5683
+ : tile.certificationStatus === 'certified' ? 'certified' : 'review_required'),
5684
+ result: tile.result,
5685
+ citation: tile.citation,
5686
+ })),
5687
+ eligibleTileIds: loaded.dashboard.story?.eligibleTileIds,
5688
+ driverTileIds: loaded.dashboard.story?.driverTileIds,
5689
+ });
5690
+ dashboardRunEvidence.set(runId, {
5691
+ appId,
5692
+ dashboardId,
5693
+ snapshotId: snapshot.snapshotId,
5694
+ filterFingerprint,
5695
+ resultFingerprint,
5696
+ personaFingerprint,
5697
+ facts: storyResult.facts,
5698
+ story: storyResult.story,
5699
+ expiresAt: Date.now() + 15 * 60_000,
5700
+ });
5701
+ for (const [id, evidence] of dashboardRunEvidence)
5702
+ if (evidence.expiresAt < Date.now())
5703
+ dashboardRunEvidence.delete(id);
5384
5704
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
5385
5705
  res.end(serializeJSON({
5386
5706
  appId,
5387
5707
  dashboardId,
5388
5708
  persona: activePersonaAppId() ? { appId: activePersonaAppId() } : null,
5709
+ runId,
5710
+ snapshotId: snapshot.snapshotId,
5711
+ filterFingerprint,
5712
+ resultFingerprint,
5713
+ personaFingerprint,
5389
5714
  tiles,
5715
+ facts: storyResult.facts,
5716
+ story: storyResult.story,
5390
5717
  }));
5391
5718
  }
5392
5719
  catch (err) {
@@ -6691,16 +7018,26 @@ export async function startLocalServer(opts) {
6691
7018
  res.end(serializeJSON({ error: 'goal is required' }));
6692
7019
  return;
6693
7020
  }
6694
- const certifiedOnly = body.certifiedOnly !== false;
6695
- const metrics = loadSemanticMetrics(projectRoot);
6696
- let blocks = collectPlanBlocks(projectRoot, { certifiedOnly });
6697
- // If nothing is certified yet, fall back to all drafts so the plan still
6698
- // shows what COULD be assembled (every section then reads as a gap to certify).
6699
- if (blocks.length === 0 && certifiedOnly)
6700
- blocks = collectPlanBlocks(projectRoot, { certifiedOnly: false });
6701
- const plan = await planApp({ goal, metrics, blocks });
7021
+ const snapshot = projectSnapshot();
7022
+ const kgPath = defaultKgPath(projectRoot);
7023
+ await reindexProject(projectRoot, { kgPath, manifest: snapshot.manifest });
7024
+ const kg = new KGStore(kgPath);
7025
+ let plan;
7026
+ try {
7027
+ plan = planAppFromPrompt({
7028
+ prompt: goal,
7029
+ kg,
7030
+ domain: typeof body.domain === 'string' ? body.domain : undefined,
7031
+ audience: typeof body.audience === 'string' ? body.audience : undefined,
7032
+ mode: body.mode === 'personal' ? 'personal' : 'stakeholder',
7033
+ snapshotId: snapshot.snapshotId,
7034
+ });
7035
+ }
7036
+ finally {
7037
+ kg.close();
7038
+ }
6702
7039
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6703
- res.end(serializeJSON({ plan, blockCount: blocks.length, metricCount: metrics.length, certifiedOnly }));
7040
+ res.end(serializeJSON({ plan, snapshotId: snapshot.snapshotId }));
6704
7041
  }
6705
7042
  catch (error) {
6706
7043
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -7595,9 +7932,35 @@ export async function startLocalServer(opts) {
7595
7932
  ?? Object.keys(connections)[0]
7596
7933
  ?? 'default';
7597
7934
  const dbtProfiles = discoverDbtProfileConnections(projectRoot, cfg);
7935
+ const activeDbtProfile = dbtProfiles.find((candidate) => candidate.missingFields.length === 0
7936
+ && !candidate.warnings.some((warning) => warning.startsWith('DuckDB file not found'))
7937
+ && !candidate.warnings.some((warning) => warning.startsWith('Not the default dbt target')));
7938
+ const activeConnection = connection ? {
7939
+ source: Object.keys(connections).length > 0 ? 'dql_config' : activeDbtProfile ? 'dbt_profile' : 'runtime',
7940
+ driver: connection.driver,
7941
+ ...(Object.keys(connections).length === 0 && activeDbtProfile ? { profileId: activeDbtProfile.id } : {}),
7942
+ } : null;
7598
7943
  const connectorStatus = getConnectorInstallStatuses(projectRoot);
7599
7944
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7600
- res.end(serializeJSON({ default: defaultKey, connections, dbtProfiles, connectorStatus }));
7945
+ res.end(serializeJSON({ default: defaultKey, connections, dbtProfiles, activeConnection, connectorStatus }));
7946
+ return;
7947
+ }
7948
+ if (req.method === 'POST' && path === '/api/connections/dbt-profiles/preview') {
7949
+ const body = await readJSON(req).catch(() => ({}));
7950
+ const profilePath = typeof body.path === 'string' ? body.path.trim() : '';
7951
+ if (!profilePath) {
7952
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7953
+ res.end(serializeJSON({ error: 'Enter a profiles.yml file or folder path.' }));
7954
+ return;
7955
+ }
7956
+ const dbtProfiles = discoverDbtProfileConnections(projectRoot, loadProjectConfig(projectRoot), profilePath);
7957
+ if (dbtProfiles.length === 0) {
7958
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
7959
+ res.end(serializeJSON({ error: 'No supported dbt profile targets were found at that path.' }));
7960
+ return;
7961
+ }
7962
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7963
+ res.end(serializeJSON({ dbtProfiles }));
7601
7964
  return;
7602
7965
  }
7603
7966
  if (req.method === "GET" && path === "/api/datasets") {
@@ -10080,7 +10443,7 @@ export function resolveDefaultLLMProvider(projectRoot) {
10080
10443
  const preferred = ['openai', 'gemini', 'anthropic', 'custom-openai', 'ollama'];
10081
10444
  for (const id of preferred) {
10082
10445
  const provider = settings.find((item) => item.id === id);
10083
- if (provider?.enabled && provider.hasApiKey)
10446
+ if (provider?.enabled && provider.configured)
10084
10447
  return id;
10085
10448
  }
10086
10449
  return null;
@@ -10663,6 +11026,18 @@ export function loadProjectConfig(projectRoot) {
10663
11026
  }
10664
11027
  return config;
10665
11028
  }
11029
+ /** Current Git-backed Skills folder and what the loader can read from it. */
11030
+ export function buildSkillPathSettings(projectRoot) {
11031
+ const resolvedPath = skillsDir(projectRoot);
11032
+ const loaded = loadSkills(projectRoot);
11033
+ return {
11034
+ path: configuredSkillsPath(projectRoot),
11035
+ resolvedPath,
11036
+ exists: existsSync(resolvedPath) && statSync(resolvedPath).isDirectory(),
11037
+ skillCount: loaded.skills.filter((skill) => skill.scope === 'project').length,
11038
+ errorCount: loaded.errors.length,
11039
+ };
11040
+ }
10666
11041
  function dbtFirstOwnsSemanticAuthoring(config) {
10667
11042
  return config.manifestVersion === 3 && config.modeling?.mode === 'dbt-first';
10668
11043
  }
@@ -11502,7 +11877,8 @@ function installConnectorPackage(projectRoot, driver) {
11502
11877
  description: 'Project-local DQL connector packages',
11503
11878
  }, null, 2) + '\n', 'utf-8');
11504
11879
  }
11505
- execFileSync('npm', ['install', '--prefix', installRoot, '--no-audit', '--no-fund', definition.packageSpec], {
11880
+ const npm = resolveNpmInvocation();
11881
+ execFileSync(npm.command, [...npm.argsPrefix, 'install', '--prefix', installRoot, '--no-audit', '--no-fund', definition.packageSpec], {
11506
11882
  cwd: projectRoot,
11507
11883
  encoding: 'utf-8',
11508
11884
  stdio: 'pipe',
@@ -12709,9 +13085,18 @@ function readUserPrefs(userPrefsPath) {
12709
13085
  return { favorites: [], recentlyUsed: [] };
12710
13086
  }
12711
13087
  const raw = JSON.parse(readFileSync(userPrefsPath, 'utf-8'));
13088
+ const acknowledgedVersion = typeof raw.setup?.acknowledgedVersion === 'string'
13089
+ ? raw.setup.acknowledgedVersion.trim()
13090
+ : '';
12712
13091
  return {
12713
13092
  favorites: Array.isArray(raw.favorites) ? raw.favorites.map(String) : [],
12714
13093
  recentlyUsed: Array.isArray(raw.recentlyUsed) ? raw.recentlyUsed.map(String) : [],
13094
+ ...(acknowledgedVersion ? {
13095
+ setup: {
13096
+ acknowledgedVersion,
13097
+ ...(typeof raw.setup?.acknowledgedAt === 'string' ? { acknowledgedAt: raw.setup.acknowledgedAt } : {}),
13098
+ },
13099
+ } : {}),
12715
13100
  };
12716
13101
  }
12717
13102
  catch {
@@ -15187,7 +15572,7 @@ async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
15187
15572
  const activeProvider = getActiveProvider(projectRoot);
15188
15573
  // Subscription CLI providers (Claude Code / Codex) carry no API key — they're
15189
15574
  // usable when enabled; their real "installed + logged in" check runs in available().
15190
- const isUsable = (provider) => provider.enabled && (provider.hasApiKey || provider.authMode === 'subscription_cli');
15575
+ const isUsable = (provider) => provider.enabled && provider.configured;
15191
15576
  const selected = requestedProvider
15192
15577
  ? settings.find((provider) => provider.id === requestedProvider && isUsable(provider))
15193
15578
  : settings.find((provider) => provider.id === activeProvider && provider.enabled)
@@ -16202,15 +16587,23 @@ function resolveDbtManifestPath(projectRoot, projectConfig = {}) {
16202
16587
  candidates.push(join(projectRoot, 'target', 'manifest.json'), join(resolve(projectRoot, '..'), 'target', 'manifest.json'), join(resolve(projectRoot, '../dbt'), 'target', 'manifest.json'), join(resolve(projectRoot, '../../dbt'), 'target', 'manifest.json'));
16203
16588
  return candidates.find((candidate, index, list) => list.indexOf(candidate) === index && existsSync(candidate));
16204
16589
  }
16205
- export function discoverDbtProfileConnections(projectRoot, projectConfig) {
16590
+ export function discoverDbtProfileConnections(projectRoot, projectConfig, explicitPath) {
16206
16591
  const dbtProjectPath = findDbtProjectPath(projectRoot, projectConfig);
16207
- const projectProfileName = readDbtProjectProfileName(dbtProjectPath);
16208
- const profilePaths = findDbtProfilePaths(projectRoot, dbtProjectPath);
16592
+ const projectProfileName = explicitPath ? null : readDbtProjectProfileName(dbtProjectPath);
16593
+ const configuredProfilesDir = projectConfig.dbt?.profilesDir
16594
+ ? resolve(projectRoot, projectConfig.dbt.profilesDir)
16595
+ : undefined;
16596
+ const profilePaths = explicitPath
16597
+ ? findExplicitDbtProfilePaths(projectRoot, explicitPath)
16598
+ : findDbtProfilePaths(projectRoot, dbtProjectPath, configuredProfilesDir);
16209
16599
  const candidates = [];
16210
16600
  for (const profilePath of profilePaths) {
16211
16601
  const profiles = readYamlFile(profilePath);
16212
16602
  if (!profiles)
16213
16603
  continue;
16604
+ const profileRelativeBase = explicitPath && existsSync(join(dirname(profilePath), 'dbt_project.yml'))
16605
+ ? dirname(profilePath)
16606
+ : dbtProjectPath;
16214
16607
  for (const [profileName, rawProfile] of Object.entries(profiles)) {
16215
16608
  if (!rawProfile || typeof rawProfile !== 'object')
16216
16609
  continue;
@@ -16234,7 +16627,7 @@ export function discoverDbtProfileConnections(projectRoot, projectConfig) {
16234
16627
  mapped.connection.filepath &&
16235
16628
  mapped.connection.filepath !== ':memory:') {
16236
16629
  if (!isAbsoluteLikePath(mapped.connection.filepath)) {
16237
- mapped.connection.filepath = resolve(dbtProjectPath, mapped.connection.filepath);
16630
+ mapped.connection.filepath = resolve(profileRelativeBase, mapped.connection.filepath);
16238
16631
  }
16239
16632
  if (!existsSync(mapped.connection.filepath)) {
16240
16633
  mapped.warnings.push(`DuckDB file not found at ${mapped.connection.filepath} — run \`dbt build\` (or \`dbt seed\`) to create it before querying.`);
@@ -16259,6 +16652,21 @@ export function discoverDbtProfileConnections(projectRoot, projectConfig) {
16259
16652
  }
16260
16653
  return candidates.slice(0, 20);
16261
16654
  }
16655
+ export function buildDbtParseArgs(dbtProjectDir, profilesDir) {
16656
+ return [
16657
+ 'parse',
16658
+ '--project-dir', dbtProjectDir,
16659
+ ...(profilesDir ? ['--profiles-dir', profilesDir] : []),
16660
+ ];
16661
+ }
16662
+ /** CFG-003: use a complete default dbt target when no saved DQL connection exists. */
16663
+ export function resolveDbtProfileRuntimeConnection(projectRoot, projectConfig) {
16664
+ const candidates = discoverDbtProfileConnections(projectRoot, projectConfig);
16665
+ const complete = candidates.filter((candidate) => candidate.missingFields.length === 0
16666
+ && !candidate.warnings.some((warning) => warning.startsWith('DuckDB file not found')));
16667
+ const defaultTarget = complete.find((candidate) => !candidate.warnings.some((warning) => warning.startsWith('Not the default dbt target')));
16668
+ return defaultTarget?.connection ?? (complete.length === 1 ? complete[0].connection : null);
16669
+ }
16262
16670
  function findDbtProjectPath(projectRoot, projectConfig) {
16263
16671
  const configuredDbtDir = projectConfig.dbt?.projectDir
16264
16672
  ? resolve(projectRoot, projectConfig.dbt.projectDir)
@@ -16279,8 +16687,18 @@ function findDbtProjectPath(projectRoot, projectConfig) {
16279
16687
  ?? semanticDbtDir
16280
16688
  ?? projectRoot;
16281
16689
  }
16282
- function findDbtProfilePaths(projectRoot, dbtProjectPath) {
16690
+ function portableProjectPath(projectRoot, absolutePath) {
16691
+ const normalizedAbsolute = resolve(absolutePath).replace(/\\/g, '/');
16692
+ const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/');
16693
+ return !relativePath
16694
+ ? '.'
16695
+ : relativePath === '..' || relativePath.startsWith('../')
16696
+ ? normalizedAbsolute
16697
+ : relativePath;
16698
+ }
16699
+ function findDbtProfilePaths(projectRoot, dbtProjectPath, configuredProfilesDir) {
16283
16700
  const dirs = [
16701
+ configuredProfilesDir,
16284
16702
  process.env.DBT_PROFILES_DIR,
16285
16703
  dbtProjectPath,
16286
16704
  projectRoot,
@@ -16288,7 +16706,7 @@ function findDbtProfilePaths(projectRoot, dbtProjectPath) {
16288
16706
  ].filter((value) => Boolean(value));
16289
16707
  const paths = [];
16290
16708
  for (const dir of dirs) {
16291
- for (const filename of ['profiles.yml', 'profiles.yaml']) {
16709
+ for (const filename of ['profiles.yml', 'profiles.yaml', 'profile.yml', 'profile.yaml']) {
16292
16710
  const profilePath = resolve(dir, filename);
16293
16711
  if (existsSync(profilePath) && !paths.includes(profilePath)) {
16294
16712
  paths.push(profilePath);
@@ -16297,6 +16715,24 @@ function findDbtProfilePaths(projectRoot, dbtProjectPath) {
16297
16715
  }
16298
16716
  return paths;
16299
16717
  }
16718
+ function findExplicitDbtProfilePaths(projectRoot, input) {
16719
+ const trimmed = input.trim();
16720
+ if (!trimmed)
16721
+ return [];
16722
+ const expanded = trimmed === '~'
16723
+ ? homedir()
16724
+ : trimmed.startsWith('~/')
16725
+ ? join(homedir(), trimmed.slice(2))
16726
+ : trimmed;
16727
+ const absolute = resolve(projectRoot, expanded);
16728
+ if (!existsSync(absolute))
16729
+ return [];
16730
+ if (!statSync(absolute).isDirectory())
16731
+ return [absolute];
16732
+ return ['profiles.yml', 'profiles.yaml', 'profile.yml', 'profile.yaml']
16733
+ .map((filename) => join(absolute, filename))
16734
+ .filter((profilePath) => existsSync(profilePath));
16735
+ }
16300
16736
  function readDbtProjectProfileName(dbtProjectPath) {
16301
16737
  const projectFile = join(dbtProjectPath, 'dbt_project.yml');
16302
16738
  const projectYaml = readYamlFile(projectFile);
@@ -16904,7 +17340,12 @@ async function readGitStatus(cwd) {
16904
17340
  behind = Number(match[0] ?? 0);
16905
17341
  ahead = Number(match[1] ?? 0);
16906
17342
  }
16907
- const statusRes = await execGit(gitRoot, ['status', '--porcelain=v1', '--untracked-files=normal']);
17343
+ // UI-001, E2E-001: the Source Control surface is file-oriented. Git's
17344
+ // "normal" untracked mode collapses a new directory into a single `?? dir/`
17345
+ // record, which prevents the UI from showing or diffing the files inside it.
17346
+ // Enumerate every untracked path so tracked and untracked changes share the
17347
+ // same per-file review contract.
17348
+ const statusRes = await execGit(gitRoot, ['status', '--porcelain=v1', '--untracked-files=all']);
16908
17349
  const changes = [];
16909
17350
  if (statusRes.code === 0) {
16910
17351
  for (const line of statusRes.stdout.split('\n')) {
@@ -17556,7 +17997,11 @@ async function testProviderConfig(projectRoot, id, overrides) {
17556
17997
  const base = getEffectiveProviderConfig(projectRoot, id);
17557
17998
  // When the user supplies inline values (testing what they typed before saving),
17558
17999
  // merge them over the saved config and test reachability regardless of enabled.
17559
- const inline = Boolean(overrides && (overrides.apiKey || overrides.baseUrl || overrides.model));
18000
+ // The test route always passes an override object, even when every draft field
18001
+ // is blank. That still means "test this candidate before save" and must allow
18002
+ // a previously configured-but-disabled provider to be reactivated without
18003
+ // forcing the user to paste its redacted secret again.
18004
+ const inline = overrides !== undefined;
17560
18005
  const config = {
17561
18006
  ...base,
17562
18007
  ...(overrides?.apiKey ? { apiKey: overrides.apiKey } : {}),
@@ -17636,15 +18081,19 @@ async function testOpenAIProviderConfig(config, label, details) {
17636
18081
  return { ok: false, message: `${label} is not configured${details}. Add an API key in Settings or OPENAI_API_KEY.` };
17637
18082
  }
17638
18083
  try {
17639
- const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl });
17640
- const response = await client.responses.create({
17641
- model: config.model ?? 'gpt-5.5',
17642
- input: 'Reply with exactly: OK',
17643
- max_output_tokens: 16,
18084
+ // Use the same provider adapter as governed Ask so enterprise gateways are
18085
+ // tested against the runtime path users will actually execute.
18086
+ const provider = new OpenAIProvider({
18087
+ apiKey: config.apiKey,
18088
+ baseUrl: config.baseUrl,
18089
+ model: config.model,
17644
18090
  });
18091
+ const response = await provider.generate([
18092
+ { role: 'user', content: 'Reply with exactly: OK' },
18093
+ ], { maxTokens: 16, temperature: 0 });
17645
18094
  return {
17646
18095
  ok: true,
17647
- message: `${label} SDK responded${details}: ${(response.output_text ?? 'OK').trim().slice(0, 80) || 'OK'}`,
18096
+ message: `${label} responded through the governed runtime${details}: ${response.trim().slice(0, 80) || 'OK'}`,
17648
18097
  };
17649
18098
  }
17650
18099
  catch (error) {
@@ -17659,20 +18108,17 @@ async function testAnthropicProviderConfig(config, label, details) {
17659
18108
  return { ok: false, message: `${label} is not configured${details}. Add an API key in Settings or ANTHROPIC_API_KEY.` };
17660
18109
  }
17661
18110
  try {
17662
- const client = new Anthropic({
18111
+ const provider = new ClaudeProvider({
17663
18112
  apiKey: config.apiKey,
17664
- ...(config.baseUrl ? { baseURL: normalizeAnthropicBaseUrl(config.baseUrl) } : {}),
18113
+ baseUrl: config.baseUrl,
18114
+ model: config.model,
17665
18115
  });
17666
- const response = await client.messages.create({
17667
- model: config.model ?? 'claude-opus-4-8',
17668
- max_tokens: 16,
17669
- temperature: 0,
17670
- messages: [{ role: 'user', content: 'Reply with exactly: OK' }],
17671
- });
17672
- const text = response.content?.filter((block) => block.type === 'text').map((block) => block.text ?? '').join('') ?? '';
18116
+ const response = await provider.generate([
18117
+ { role: 'user', content: 'Reply with exactly: OK' },
18118
+ ], { maxTokens: 16, temperature: 0 });
17673
18119
  return {
17674
18120
  ok: true,
17675
- message: `${label} SDK responded${details}: ${text.trim().slice(0, 80) || 'OK'}`,
18121
+ message: `${label} responded through the governed runtime${details}: ${response.trim().slice(0, 80) || 'OK'}`,
17676
18122
  };
17677
18123
  }
17678
18124
  catch (error) {