@duckcodeailabs/dql-cli 1.8.1 → 1.8.3

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.
@@ -14,9 +14,12 @@ import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetri
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
+ import { rethrowIfCancelled } from './llm/cancellation.js';
18
+ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from './version-status.js';
19
+ import { resolveRetrievalHealthStatus } from './retrieval-health.js';
17
20
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
18
21
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
19
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, buildLocalContextPack, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, 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, recordAgentRuntimeVersion, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
22
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, buildLocalContextPack, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
20
23
  import { gatherProposeEnrichment } from './propose-enrich.js';
21
24
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
22
25
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -145,6 +148,7 @@ export function parseAgentRunRequestBody(body) {
145
148
  return {
146
149
  request: {
147
150
  question,
151
+ selectedEvidenceId: agentRunString(record.selectedEvidenceId),
148
152
  requestedMode,
149
153
  audience,
150
154
  intent: agentRunString(record.intent),
@@ -163,17 +167,26 @@ export function parseAgentRunRequestBody(body) {
163
167
  }
164
168
  const AGENT_LOOKUP_DEADLINE_MS = 45_000;
165
169
  const AGENT_RESEARCH_DEADLINE_MS = 120_000;
170
+ /** Env-tunable run deadline (e.g. slow subscription-CLI providers), clamped to sane bounds. */
171
+ function resolveAgentDeadlineMs(envKey, fallback, env = process.env) {
172
+ const configured = Number(env[envKey]);
173
+ if (!Number.isFinite(configured) || configured <= 0)
174
+ return fallback;
175
+ return Math.max(15_000, Math.min(600_000, Math.floor(configured)));
176
+ }
166
177
  /**
167
178
  * PERF-002: one wall-clock budget follows the request through routing, provider
168
179
  * calls, repair, and execution. Ordinary Ask never inherits Research's budget
169
180
  * merely because it spans two tables; explicit/deep investigation does.
170
181
  */
171
- export function agentRunDeadlineMs(request) {
182
+ export function agentRunDeadlineMs(request, env = process.env) {
183
+ const lookupDeadline = resolveAgentDeadlineMs('DQL_AGENT_LOOKUP_DEADLINE_MS', AGENT_LOOKUP_DEADLINE_MS, env);
184
+ const researchDeadline = resolveAgentDeadlineMs('DQL_AGENT_RESEARCH_DEADLINE_MS', AGENT_RESEARCH_DEADLINE_MS, env);
172
185
  if (request.requestedMode === 'research' || request.analysisDepth === 'deep') {
173
- return AGENT_RESEARCH_DEADLINE_MS;
186
+ return researchDeadline;
174
187
  }
175
188
  const plan = buildAnalysisQuestionPlan(request.question);
176
- return plan.needsResearchWorkspace ? AGENT_RESEARCH_DEADLINE_MS : AGENT_LOOKUP_DEADLINE_MS;
189
+ return plan.needsResearchWorkspace ? researchDeadline : lookupDeadline;
177
190
  }
178
191
  export function shouldSynthesizeAgentRunAnswer(governedAnswer) {
179
192
  if (governedAnswer.kind === 'no_answer')
@@ -197,6 +210,10 @@ export function shouldSynthesizeAgentRunAnswer(governedAnswer) {
197
210
  return false;
198
211
  return true;
199
212
  }
213
+ export function agentAnswerHasExecutionFailure(governedAnswer) {
214
+ return typeof governedAnswer.executionError === 'string'
215
+ && governedAnswer.executionError.trim().length > 0;
216
+ }
200
217
  function businessNarrativeGaps(warnings) {
201
218
  const businessRelevant = (warnings ?? []).filter((warning) => !(/missing requested output column/i.test(warning)
202
219
  || /\bquery plan\b/i.test(warning)
@@ -210,7 +227,11 @@ function businessNarrativeGaps(warnings) {
210
227
  // source of prior turns (survives refresh); the client-built context remains
211
228
  // the fallback for embedders that never send a threadId.
212
229
  async function conversationContextFromThread(store, threadId, clientContext, question) {
213
- const turns = store.recentTurns(threadId, 8).map((turn) => {
230
+ // Token-budget backstop: six verbatim turns with per-field caps. Older turns
231
+ // remain reachable through the rolling summary + semantic recall below —
232
+ // carrying more raw prose mostly slows every provider call on follow-ups.
233
+ const clampTurnText = (value) => value && value.length > 1_200 ? `${value.slice(0, 1_200)}…` : value;
234
+ const turns = store.recentTurns(threadId, 6).map((turn) => {
214
235
  const contract = turn.contract ?? {};
215
236
  const topN = contract.topN;
216
237
  const topNValue = typeof topN === 'number'
@@ -220,8 +241,8 @@ async function conversationContextFromThread(store, threadId, clientContext, que
220
241
  : undefined;
221
242
  return compactConversationRecord({
222
243
  id: turn.id,
223
- question: turn.question,
224
- answerSummary: turn.answerSummary,
244
+ question: clampTurnText(turn.question),
245
+ answerSummary: clampTurnText(turn.answerSummary),
225
246
  sourceCertifiedBlock: turn.sourceCertifiedBlock,
226
247
  route: turn.route,
227
248
  trustLabel: turn.trustLabel,
@@ -280,16 +301,20 @@ function recordConversationTurn(store, threadId, run) {
280
301
  // Conversation persistence is additive; a failed write must not fail the run.
281
302
  }
282
303
  }
283
- function conversationTurnInputFromRun(run) {
304
+ export function conversationTurnInputFromRun(run) {
284
305
  const artifact = run.artifacts.find((candidate) => candidate.kind === 'answer')
285
306
  ?? run.artifacts.find((candidate) => candidate.kind === 'research_run')
286
307
  ?? run.artifacts[0];
287
308
  const payload = agentRunRecord(artifact?.payload);
288
309
  const result = agentRunRecord(payload?.result);
289
310
  const columns = conversationResultColumns(result?.columns);
290
- const rows = Array.isArray(result?.rows)
291
- ? result.rows.filter((row) => Boolean(row && typeof row === 'object' && !Array.isArray(row))).slice(0, 8)
311
+ // The visual preview stays tiny, but member resolution needs a wider bounded
312
+ // value window. Deriving dimensions from only the eight preview rows caused a
313
+ // valid row 9/10 member to disappear before the next turn (AGT-012/E2E-010).
314
+ const memberRows = Array.isArray(result?.rows)
315
+ ? result.rows.filter((row) => Boolean(row && typeof row === 'object' && !Array.isArray(row))).slice(0, 24)
292
316
  : [];
317
+ const rows = memberRows.slice(0, 8);
293
318
  const rowsSample = rows.map((row) => columns.map((column) => row[column]));
294
319
  const contextPack = agentRunRecord(payload?.contextPack);
295
320
  const questionPlan = agentRunRecord(contextPack?.questionPlan);
@@ -313,7 +338,7 @@ function conversationTurnInputFromRun(run) {
313
338
  ? {
314
339
  columns,
315
340
  rowsSample,
316
- dimensionValues: conversationDimensionValues(columns, rows),
341
+ dimensionValues: conversationDimensionValues(columns, memberRows),
317
342
  measureColumns,
318
343
  rowCount: typeof rowCountRaw === 'number' ? rowCountRaw : rows.length || undefined,
319
344
  }
@@ -416,6 +441,8 @@ export async function startLocalServer(opts) {
416
441
  const loopback = bindHost === '127.0.0.1' || bindHost === 'localhost' || bindHost === '::1';
417
442
  const authToken = opts.authToken ?? process.env.DQL_SERVER_TOKEN;
418
443
  const runtimeVersion = readDqlRuntimeVersion();
444
+ // Warm the latest-version cache in the background (2s cap, 24h cache; offline → unknown).
445
+ void fetchLatestPublishedDqlVersion();
419
446
  const allowedOrigins = new Set((opts.allowedOrigins ?? (process.env.DQL_ALLOWED_ORIGINS ?? '').split(','))
420
447
  .map((value) => value.trim().replace(/\/$/, ''))
421
448
  .filter(Boolean));
@@ -1084,7 +1111,12 @@ export async function startLocalServer(opts) {
1084
1111
  await runner.run({
1085
1112
  provider: resolvedProvider,
1086
1113
  messages: [
1087
- ...(request.history ?? []).map((message) => ({ role: message.role, content: message.text })),
1114
+ // No-thread fallback path: bound the raw client history so follow-ups
1115
+ // cannot inflate every provider call (8 turns × 4k chars).
1116
+ ...(request.history ?? []).slice(-8).map((message) => ({
1117
+ role: message.role,
1118
+ content: message.text.length > 4_000 ? `${message.text.slice(0, 4_000)}…` : message.text,
1119
+ })),
1088
1120
  ...(isRepair
1089
1121
  ? [{ role: 'assistant', content: `The prior attempt needs repair without changing the original question or requested output grain: ${repair?.repairHint}` }]
1090
1122
  : []),
@@ -1420,6 +1452,9 @@ export async function startLocalServer(opts) {
1420
1452
  applySmartVisualization(governedAnswer, request.question);
1421
1453
  }
1422
1454
  catch (error) {
1455
+ // Deadline/cancellation propagates to the engine, which renders the
1456
+ // graceful "bounded execution deadline" outcome — never a provider error.
1457
+ rethrowIfCancelled(error, request.signal);
1423
1458
  const message = formatAgentRunInfrastructureError(error, 'AI answer provider');
1424
1459
  return {
1425
1460
  summary: message,
@@ -1445,8 +1480,9 @@ export async function startLocalServer(opts) {
1445
1480
  || governedAnswer.sourceCertifiedBlock
1446
1481
  || governedAnswer.dqlArtifact?.kind === 'certified_block'));
1447
1482
  const isExploratory = Boolean(governedAnswer.exploratoryCandidate);
1483
+ const isExecutionFailure = agentAnswerHasExecutionFailure(governedAnswer);
1448
1484
  const isGroundingGap = governedAnswer.kind === 'no_answer'
1449
- && governedAnswer.refusalCode === 'grounding_gap'
1485
+ && (governedAnswer.refusalCode === 'grounding_gap' || governedAnswer.refusalCode === 'modeling_gap')
1450
1486
  && !isExploratory;
1451
1487
  const isProviderError = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'provider_error';
1452
1488
  // AGT-004: a rejected attribution/export/proof policy is a deliberate
@@ -1465,7 +1501,7 @@ export async function startLocalServer(opts) {
1465
1501
  const needsClarification = governedAnswer.kind === 'no_answer'
1466
1502
  && !isGroundingGap && !isProviderError && !isModelDeclined && !isPolicyBlocked;
1467
1503
  const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
1468
- const runnableSql = governedAnswer.kind === 'no_answer' || (isExploratory && !governedAnswer.result)
1504
+ const runnableSql = governedAnswer.kind === 'no_answer' || isExecutionFailure || (isExploratory && !governedAnswer.result)
1469
1505
  ? undefined
1470
1506
  : sql;
1471
1507
  // Render executed rows deterministically for ordinary lookups. A second LLM
@@ -1504,14 +1540,14 @@ export async function startLocalServer(opts) {
1504
1540
  synthesizedAnswer = undefined;
1505
1541
  }
1506
1542
  }
1507
- const status = isProviderError
1543
+ const status = isProviderError || isExecutionFailure
1508
1544
  ? 'blocked'
1509
1545
  : needsClarification
1510
1546
  ? 'needs_clarification'
1511
1547
  : isCertified || isSemantic
1512
1548
  ? 'completed'
1513
1549
  : 'needs_review';
1514
- const trustState = isProviderError
1550
+ const trustState = isProviderError || isExecutionFailure
1515
1551
  ? 'blocked'
1516
1552
  : needsClarification
1517
1553
  ? 'not_applicable'
@@ -1520,7 +1556,7 @@ export async function startLocalServer(opts) {
1520
1556
  : isSemantic
1521
1557
  ? 'governed'
1522
1558
  : 'review_required';
1523
- const stopReason = isProviderError
1559
+ const stopReason = isProviderError || isExecutionFailure
1524
1560
  ? 'blocked'
1525
1561
  : needsClarification
1526
1562
  ? 'needs_clarification'
@@ -1531,33 +1567,41 @@ export async function startLocalServer(opts) {
1531
1567
  : 'human_review_required';
1532
1568
  const nextActions = needsClarification
1533
1569
  ? [{ id: 'clarify', label: 'Clarify question', route: 'generated_answer' }]
1534
- : [
1535
- { id: 'create-block', label: governedAnswer.dqlArtifact ? 'Review DQL draft' : 'Create DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
1536
- { id: 'research-gap', label: 'Research deeper', route: 'research' },
1537
- ...(runnableSql ? [{
1538
- id: 'insert-sql',
1539
- label: governedAnswer.dqlArtifact ? 'Insert as DQL cell' : 'Insert SQL preview',
1540
- route: 'sql_cell',
1541
- artifactKind: 'sql_cell',
1542
- }] : []),
1543
- ];
1570
+ : isExecutionFailure
1571
+ ? [{ id: 'retry-after-connection', label: 'Retry after fixing the database connection', route: 'generated_answer' }]
1572
+ : isGroundingGap
1573
+ ? [{ id: 'research-gap', label: 'Research missing metadata coverage', route: 'research', artifactKind: 'research_run' }]
1574
+ : [
1575
+ { id: 'create-block', label: governedAnswer.dqlArtifact ? 'Review DQL draft' : 'Create DQL draft', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
1576
+ { id: 'research-gap', label: 'Research deeper', route: 'research' },
1577
+ ...(runnableSql ? [{
1578
+ id: 'insert-sql',
1579
+ label: governedAnswer.dqlArtifact ? 'Insert as DQL cell' : 'Insert SQL preview',
1580
+ route: 'sql_cell',
1581
+ artifactKind: 'sql_cell',
1582
+ }] : []),
1583
+ ];
1544
1584
  return {
1545
1585
  resolvedRoute,
1546
1586
  answerRefusalCode: governedAnswer.kind === 'no_answer' ? governedAnswer.refusalCode : undefined,
1547
1587
  answerTier: governedAnswer.route?.tier,
1548
- summary: governedAnswer.route?.label ?? (isCertified ? 'Answered from certified DQL context.' : isExploratory ? 'Exploratory DBT-grounded analysis requires review.' : 'Answered with review-required generated analysis.'),
1588
+ summary: isExecutionFailure
1589
+ ? 'The governed query could not be executed.'
1590
+ : governedAnswer.route?.label ?? (isCertified ? 'Answered from certified DQL context.' : isExploratory ? 'Exploratory DBT-grounded analysis requires review.' : 'Answered with review-required generated analysis.'),
1549
1591
  answer: synthesizedAnswer ?? governedAnswer.answer ?? governedAnswer.text,
1550
1592
  status,
1551
1593
  trustState,
1552
1594
  stopReason,
1553
- artifacts: governedAnswer.kind === 'no_answer'
1554
- // A refusal still keeps the DQL draft the answer loop produced (when any),
1555
- // so the "Review DQL draft" next-action isn't a dead link and the user can
1556
- // see the SQL that was about to run. Provider outages carry no draft.
1557
- ? (governedAnswer.dqlArtifact && !isProviderError
1558
- ? [agentRunArtifact('dql_block_draft', 'DQL draft (review required)', governedAnswer.dqlArtifact, undefined, 'review_required')]
1559
- : [])
1560
- : [agentRunArtifact('answer', isCertified ? 'Certified answer' : isSemantic ? 'Governed semantic answer' : isExploratory ? 'Exploratory DBT-grounded answer' : 'Review-required answer', governedAnswer, governedAnswer.sourceCertifiedBlock ?? governedAnswer.block?.name, isCertified ? 'certified' : isSemantic ? 'governed' : 'review_required')],
1595
+ artifacts: isExecutionFailure
1596
+ ? []
1597
+ : governedAnswer.kind === 'no_answer'
1598
+ // A refusal still keeps the DQL draft the answer loop produced (when any),
1599
+ // so the "Review DQL draft" next-action isn't a dead link and the user can
1600
+ // see the SQL that was about to run. Provider outages carry no draft.
1601
+ ? (governedAnswer.dqlArtifact && !isProviderError && !isGroundingGap && !isModelDeclined && !isPolicyBlocked
1602
+ ? [agentRunArtifact('dql_block_draft', 'DQL draft (review required)', governedAnswer.dqlArtifact, undefined, 'review_required')]
1603
+ : [])
1604
+ : [agentRunArtifact('answer', isCertified ? 'Certified answer' : isSemantic ? 'Governed semantic answer' : isExploratory ? 'Exploratory DBT-grounded answer' : 'Review-required answer', governedAnswer, governedAnswer.sourceCertifiedBlock ?? governedAnswer.block?.name, isCertified ? 'certified' : isSemantic ? 'governed' : 'review_required')],
1561
1605
  evaluations: [
1562
1606
  agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to governed answer.', {
1563
1607
  plannedRoute: route,
@@ -1586,6 +1630,7 @@ export async function startLocalServer(opts) {
1586
1630
  : isExploratory
1587
1631
  ? 'The answer used bounded DBT/schema evidence because no certified modeled relationship path covered the request. It is review-required and was not certified.'
1588
1632
  : 'The answer is generated or semantic-layer backed and remains review-required.', governedAnswer.route),
1633
+ ...(isExecutionFailure ? [agentRunEvaluation('query-execution', 'Query execution', false, 'blocking', `The governed query failed before it produced a result: ${governedAnswer.executionError}`)] : []),
1589
1634
  ...(isGroundingGap ? [
1590
1635
  {
1591
1636
  ...agentRunEvaluation('grounding-gap', 'Metadata grounding', false, 'warning', 'The bounded lookup could not prove the required metadata grounding. No automatic retry or Research escalation was started.', {
@@ -1767,6 +1812,7 @@ export async function startLocalServer(opts) {
1767
1812
  }
1768
1813
  }
1769
1814
  catch (error) {
1815
+ rethrowIfCancelled(error, request.signal);
1770
1816
  researchWorkspaceError = formatNotebookResearchStorageError(error);
1771
1817
  }
1772
1818
  }
@@ -2171,6 +2217,7 @@ export async function startLocalServer(opts) {
2171
2217
  : undefined;
2172
2218
  const task = buildLocalContextPack(projectRoot, {
2173
2219
  question: request.question,
2220
+ focusObjectKey: request.selectedEvidenceId,
2174
2221
  followUp,
2175
2222
  priorContextPackId: agentRunString(request.conversationContext?.contextPackId),
2176
2223
  conversationTopicRelation: topicRelation === 'continuation'
@@ -2246,9 +2293,9 @@ export async function startLocalServer(opts) {
2246
2293
  };
2247
2294
  }
2248
2295
  if ((candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
2249
- && semanticEvidence.has(candidate.id)
2250
- && pack.routeDecision.route !== 'clarify'
2251
- && pack.routeDecision.route !== 'conflict') {
2296
+ && (semanticEvidence.has(candidate.id) || request.selectedEvidenceId === candidate.id)
2297
+ && (request.selectedEvidenceId === candidate.id
2298
+ || (pack.routeDecision.route !== 'clarify' && pack.routeDecision.route !== 'conflict'))) {
2252
2299
  const requestedDimensions = pack.questionPlan.requestedShape.dimensions.map((dimension) => dimension.toLowerCase());
2253
2300
  const availableDimensions = (candidate.dimensions ?? []).map((dimension) => dimension.toLowerCase());
2254
2301
  const dimensionsFit = requestedDimensions.length === 0 || requestedDimensions.every((requested) => availableDimensions.some((available) => available === requested || available.endsWith(`.${requested}`)));
@@ -2335,7 +2382,13 @@ export async function startLocalServer(opts) {
2335
2382
  getEvidence: buildAgentRunEvidence,
2336
2383
  getCatalogContext: buildRankedAgentRunCatalogContext,
2337
2384
  });
2338
- const agentRunStore = new FileAgentRunStore({ path: defaultAgentRunStorePath(projectRoot) });
2385
+ // P0: one row per run with retention + old-run compaction. The legacy JSON
2386
+ // store rewrote the entire file (123 MB observed) twice per answered question;
2387
+ // existing history is imported once and the JSON renamed to *.migrated.
2388
+ const agentRunStore = new SqliteAgentRunStore({
2389
+ path: defaultAgentRunSqlitePath(projectRoot),
2390
+ legacyJsonPath: defaultAgentRunStorePath(projectRoot),
2391
+ });
2339
2392
  // A run may outlive its streaming browser connection, so cancellation is
2340
2393
  // server-owned and keyed by run id rather than relying on fetch abort alone.
2341
2394
  const activeAgentRunControllers = new Map();
@@ -2557,7 +2610,7 @@ export async function startLocalServer(opts) {
2557
2610
  const preflight = repairExploratorySqlBeforeExecution(candidate.sql, schemaContext, question);
2558
2611
  const boundedSql = applyRequestedTopNToExploratorySql(preflight.sql, requestedTopN);
2559
2612
  const repairs = boundedSql === preflight.sql
2560
- ? preflight.repairs
2613
+ ? [...preflight.repairs]
2561
2614
  : [...preflight.repairs, `Applied the requested overall top-${requestedTopN} bound before exploratory execution.`];
2562
2615
  if (preflight.blockedReason) {
2563
2616
  return {
@@ -2571,19 +2624,62 @@ export async function startLocalServer(opts) {
2571
2624
  if (!analysis.parsed) {
2572
2625
  return { proofs: [], sql: boundedSql, repairs, error: 'DQL could not parse the exploratory SQL to validate its join predicates.' };
2573
2626
  }
2627
+ const probeableJoins = probeableExploratoryJoins(analysis.joins, analysis.ctes);
2574
2628
  // A normal customer → order → order-item → product question needs three
2575
2629
  // joins. Keep the lane bounded, but do not reject this common dbt-star path
2576
- // solely because the old two-join demo limit was too small.
2577
- if (analysis.joins.length > 4) {
2630
+ // solely because the old two-join demo limit was too small. CTE-internal
2631
+ // joins are counted physically (they appear in analysis.joins with their
2632
+ // physical endpoints); joins ON a CTE are restructuring, not new paths.
2633
+ if (probeableJoins.length > 4) {
2578
2634
  return { proofs: [], sql: boundedSql, repairs, error: 'This exploratory query has more than four joins and requires an analyst to review the relationship path.' };
2579
2635
  }
2580
- if (analysis.joins.some((join) => !join.leftRelation || !join.rightRelation)) {
2636
+ if (probeableJoins.some((join) => !join.leftRelation || !join.rightRelation)) {
2581
2637
  return { proofs: [], sql: boundedSql, repairs, error: 'This exploratory query has a join endpoint DQL could not resolve for bounded validation.' };
2582
2638
  }
2639
+ // Structural binding: when the candidate carries a declared draft join path,
2640
+ // every SQL join must ride one of its edges on the declared key pair. A join
2641
+ // outside the declared path (or on different keys) is not executed — it is a
2642
+ // review-required modeling question, not a runtime guess.
2643
+ const declaredEdges = candidate.exploratoryPath?.edges ?? [];
2644
+ const joinEdges = new Map();
2645
+ if (declaredEdges.length > 0) {
2646
+ for (const [index, join] of probeableJoins.entries()) {
2647
+ const edge = declaredEdges.find((value) => declaredExploratoryRelationMatches(join.leftRelation, value.fromRelation) && declaredExploratoryRelationMatches(join.rightRelation, value.toRelation)
2648
+ || declaredExploratoryRelationMatches(join.leftRelation, value.toRelation) && declaredExploratoryRelationMatches(join.rightRelation, value.fromRelation));
2649
+ if (!edge) {
2650
+ return {
2651
+ proofs: [],
2652
+ sql: boundedSql,
2653
+ repairs,
2654
+ error: `The join between ${join.leftRelation} and ${join.rightRelation} is not part of the declared relationship path, so DQL did not execute it. Declare the relationship in the DQL model to enable it.`,
2655
+ };
2656
+ }
2657
+ const keyMatches = edge.keys.some((key) => exploratoryColumnsMatch(join.leftColumn, key.from) && exploratoryColumnsMatch(join.rightColumn, key.to)
2658
+ || exploratoryColumnsMatch(join.leftColumn, key.to) && exploratoryColumnsMatch(join.rightColumn, key.from));
2659
+ if (!keyMatches) {
2660
+ return {
2661
+ proofs: [],
2662
+ sql: boundedSql,
2663
+ repairs,
2664
+ error: `The join ${join.leftColumn} = ${join.rightColumn} uses different keys than relationship "${edge.relationshipId}" declares (${edge.keys.map((key) => `${key.from}=${key.to}`).join(', ')}). Review the declared keys before executing.`,
2665
+ };
2666
+ }
2667
+ joinEdges.set(index, edge);
2668
+ }
2669
+ const exercised = new Set([...joinEdges.values()].map((edge) => edge.relationshipId));
2670
+ for (const edge of declaredEdges) {
2671
+ if (!exercised.has(edge.relationshipId)) {
2672
+ repairs.push(`Declared relationship "${edge.relationshipId}" was not exercised by this query.`);
2673
+ }
2674
+ }
2675
+ }
2676
+ if (probeableJoins.length < analysis.joins.length) {
2677
+ repairs.push(`Skipped join probes for ${analysis.joins.length - probeableJoins.length} join(s) on derived (CTE) relations; the final query execution validates them.`);
2678
+ }
2583
2679
  const activeConnection = requireActiveConnection();
2584
2680
  const proofs = [];
2585
2681
  try {
2586
- for (const join of analysis.joins) {
2682
+ for (const [index, join] of probeableJoins.entries()) {
2587
2683
  const proofSql = buildExploratoryJoinProbeSql({
2588
2684
  leftRelation: join.leftRelation,
2589
2685
  leftColumn: join.leftColumn,
@@ -2593,6 +2689,14 @@ export async function startLocalServer(opts) {
2593
2689
  const prepared = prepareLocalExecution(proofSql, activeConnection, projectRoot, projectConfig);
2594
2690
  const raw = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
2595
2691
  proofs.push({ summary: summarizeExploratoryJoinProbe(raw.rows, join.leftRelation, join.leftColumn, join.rightRelation, join.rightColumn) });
2692
+ // Gating (not observational): a structural contradiction between the
2693
+ // UNFILTERED key overlap and the declared relationship stops automatic
2694
+ // execution. A legitimately empty *filtered* business result is still an
2695
+ // answer — these gates only inspect the raw key samples.
2696
+ const contradiction = exploratoryProbeContradiction(raw.rows, join, joinEdges.get(index));
2697
+ if (contradiction) {
2698
+ return { proofs, sql: boundedSql, repairs, error: contradiction };
2699
+ }
2596
2700
  }
2597
2701
  const result = await executeGeneratedSqlForAgent(boundedSql);
2598
2702
  return { result, proofs, sql: boundedSql, repairs };
@@ -3713,8 +3817,19 @@ export async function startLocalServer(opts) {
3713
3817
  }
3714
3818
  }
3715
3819
  if (req.method === 'GET' && path === '/api/health') {
3820
+ // REL-002: expose runtime/build identity so a server started before a
3821
+ // rebuild or running a different version than the project pin is visibly
3822
+ // stale. Uses only the cached latest-version lookup — never blocks.
3823
+ const versionStatus = resolveDqlRuntimeVersionStatus({ projectRoot, runningVersion: runtimeVersion });
3824
+ void fetchLatestPublishedDqlVersion();
3825
+ const healthValueGrounding = resolveAgentRuntimeValueGrounding(projectConfig);
3826
+ const retrievalHealth = resolveRetrievalHealthStatus({
3827
+ projectRoot,
3828
+ valueGroundingMode: healthValueGrounding.mode,
3829
+ searchSafeColumnCount: healthValueGrounding.searchSafeColumns.size,
3830
+ });
3716
3831
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3717
- res.end(serializeJSON({ status: 'ok', version: runtimeVersion }));
3832
+ res.end(serializeJSON({ status: 'ok', version: runtimeVersion, versionStatus, retrievalHealth }));
3718
3833
  return;
3719
3834
  }
3720
3835
  if (req.method === 'GET' && path === '/api/onboarding/launch') {
@@ -4523,6 +4638,11 @@ export async function startLocalServer(opts) {
4523
4638
  const conversationStore = parsed.request.threadId ? getConversationStore() : null;
4524
4639
  if (conversationStore && parsed.request.threadId && conversationStore.getThread(parsed.request.threadId)) {
4525
4640
  parsed.request.conversationContext = await conversationContextFromThread(conversationStore, parsed.request.threadId, parsed.request.conversationContext, parsed.request.question);
4641
+ // The persisted thread is authoritative for prior turns. Raw client
4642
+ // history would duplicate the same conversation into the prompt a
4643
+ // second time — dropping it keeps follow-up prompts bounded.
4644
+ if (parsed.request.history?.length)
4645
+ parsed.request.history = [];
4526
4646
  }
4527
4647
  const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
4528
4648
  const runId = parsed.request.runId;
@@ -8722,6 +8842,12 @@ export async function startLocalServer(opts) {
8722
8842
  }));
8723
8843
  return;
8724
8844
  }
8845
+ const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
8846
+ const dbtManifestReady = provider === 'dbt'
8847
+ ? hasDbtSemanticManifest(projectRoot, semanticConfig?.projectPath)
8848
+ : false;
8849
+ const metricFlowReady = provider === 'dbt' ? hasMetricFlowCli() : false;
8850
+ const dbtExecutionReady = dbtManifestReady && metricFlowReady;
8725
8851
  const metrics = semanticLayer.listMetrics().map((m) => ({
8726
8852
  name: m.name,
8727
8853
  label: m.label,
@@ -8736,6 +8862,7 @@ export async function startLocalServer(opts) {
8736
8862
  typeParams: m.typeParams ?? null,
8737
8863
  filter: m.filter ?? null,
8738
8864
  source: m.source ?? null,
8865
+ execution: semanticMetricExecutionCapability(m.name, semanticLayer, provider, metricFlowReady, connection?.driver),
8739
8866
  }));
8740
8867
  const measures = semanticLayer.listMeasures().map((m) => ({
8741
8868
  name: m.name,
@@ -8833,12 +8960,6 @@ export async function startLocalServer(opts) {
8833
8960
  owner: q.owner ?? null,
8834
8961
  source: q.source ?? null,
8835
8962
  }));
8836
- const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
8837
- const dbtManifestReady = provider === 'dbt'
8838
- ? hasDbtSemanticManifest(projectRoot, semanticConfig?.projectPath)
8839
- : false;
8840
- const metricFlowReady = provider === 'dbt' ? hasMetricFlowCli() : false;
8841
- const dbtExecutionReady = dbtManifestReady && metricFlowReady;
8842
8963
  const dbtExecutionSetup = dbtExecutionReady
8843
8964
  ? null
8844
8965
  : !dbtManifestReady && !metricFlowReady
@@ -9753,8 +9874,20 @@ export async function startLocalServer(opts) {
9753
9874
  tableMapping,
9754
9875
  });
9755
9876
  if (!composed) {
9877
+ const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
9878
+ const metricFlowReady = provider === 'dbt' && hasMetricFlowCli();
9879
+ const blocked = metrics.map((metricName) => ({
9880
+ metric: metricName,
9881
+ ...semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, driver),
9882
+ })).filter((capability) => capability.status !== 'ready');
9756
9883
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
9757
- res.end(serializeJSON({ error: `Could not compose query for metrics: [${metrics.join(', ')}]` }));
9884
+ res.end(serializeJSON({
9885
+ error: blocked.length > 0
9886
+ ? blocked.map((capability) => `${capability.metric}: ${capability.reason}`).join(' ')
9887
+ : 'The selected dimensions do not share a governed join path with every selected metric.',
9888
+ code: blocked.length > 0 ? 'SEMANTIC_RUNTIME_REQUIRED' : 'SEMANTIC_FIELDS_INCOMPATIBLE',
9889
+ details: { metrics, dimensions, blocked },
9890
+ }));
9758
9891
  return;
9759
9892
  }
9760
9893
  // Execute the composed SQL against the resolved connection
@@ -9839,8 +9972,20 @@ export async function startLocalServer(opts) {
9839
9972
  tableMapping,
9840
9973
  });
9841
9974
  if (!composed) {
9975
+ const provider = semanticConfig?.provider ?? semanticDetectedProvider ?? 'dql';
9976
+ const metricFlowReady = provider === 'dbt' && hasMetricFlowCli();
9977
+ const blocked = metrics.map((metricName) => ({
9978
+ metric: metricName,
9979
+ ...semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, driver),
9980
+ })).filter((capability) => capability.status !== 'ready');
9842
9981
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
9843
- res.end(serializeJSON({ error: 'Could not compose semantic block preview SQL.' }));
9982
+ res.end(serializeJSON({
9983
+ error: blocked.length > 0
9984
+ ? blocked.map((capability) => `${capability.metric}: ${capability.reason}`).join(' ')
9985
+ : 'The selected dimensions do not share a governed join path with every selected metric.',
9986
+ code: blocked.length > 0 ? 'SEMANTIC_RUNTIME_REQUIRED' : 'SEMANTIC_FIELDS_INCOMPATIBLE',
9987
+ details: { metrics, dimensions, blocked },
9988
+ }));
9844
9989
  return;
9845
9990
  }
9846
9991
  const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
@@ -13063,6 +13208,16 @@ export function buildExploratoryJoinProbeSql(input) {
13063
13208
  SELECT right_key, COUNT(*) AS match_count
13064
13209
  FROM matches
13065
13210
  GROUP BY right_key
13211
+ ),
13212
+ left_key_counts AS (
13213
+ SELECT join_key, COUNT(*) AS key_count
13214
+ FROM left_sample
13215
+ GROUP BY join_key
13216
+ ),
13217
+ right_key_counts AS (
13218
+ SELECT join_key, COUNT(*) AS key_count
13219
+ FROM right_sample
13220
+ GROUP BY join_key
13066
13221
  )
13067
13222
  SELECT
13068
13223
  (SELECT COUNT(*) FROM left_sample) AS left_sample_rows,
@@ -13071,7 +13226,87 @@ SELECT
13071
13226
  (SELECT COUNT(*) FROM left_sample AS l WHERE NOT EXISTS (SELECT 1 FROM right_sample AS r WHERE r.join_key = l.join_key)) AS unmatched_left_sample_rows,
13072
13227
  (SELECT COUNT(*) FROM right_sample AS r WHERE NOT EXISTS (SELECT 1 FROM left_sample AS l WHERE l.join_key = r.join_key)) AS unmatched_right_sample_rows,
13073
13228
  (SELECT COALESCE(MAX(match_count), 0) FROM left_counts) AS max_matches_per_left_key,
13074
- (SELECT COALESCE(MAX(match_count), 0) FROM right_counts) AS max_matches_per_right_key`;
13229
+ (SELECT COALESCE(MAX(match_count), 0) FROM right_counts) AS max_matches_per_right_key,
13230
+ (SELECT COALESCE(MAX(key_count), 0) FROM left_key_counts) AS max_left_rows_per_key,
13231
+ (SELECT COALESCE(MAX(key_count), 0) FROM right_key_counts) AS max_right_rows_per_key`;
13232
+ }
13233
+ /**
13234
+ * CTE names are query-internal derived relations, not warehouse tables. A join
13235
+ * whose endpoint is a CTE (e.g. `WITH joy_items AS (…) … JOIN joy_items`) must
13236
+ * be excluded from declared-path enforcement and from join probes — probing
13237
+ * `FROM "joy_items"` throws a DuckDB catalog error for a table that was never
13238
+ * supposed to exist. The physical joins INSIDE the CTE are still present in
13239
+ * the parsed join list and get validated/probed on their own.
13240
+ */
13241
+ export function probeableExploratoryJoins(joins, ctes) {
13242
+ const cteNames = new Set(ctes.map((name) => name.split('.').at(-1)?.toLowerCase() ?? name.toLowerCase()));
13243
+ if (cteNames.size === 0)
13244
+ return joins;
13245
+ return joins.filter((join) => ![join.leftRelation, join.rightRelation].some((relation) => {
13246
+ const bare = relation?.replace(/["`\[\]]/g, '').split('.').at(-1)?.toLowerCase();
13247
+ return Boolean(bare && cteNames.has(bare));
13248
+ }));
13249
+ }
13250
+ function declaredExploratoryRelationMatches(sqlRelation, declaredRelation) {
13251
+ if (!declaredRelation)
13252
+ return false;
13253
+ const normalize = (value) => value.replace(/["`\[\]]/g, '').trim();
13254
+ return exploratoryRelationsMatch(normalize(sqlRelation), normalize(declaredRelation));
13255
+ }
13256
+ function exploratoryColumnsMatch(sqlColumn, declaredColumn) {
13257
+ const normalize = (value) => value.replace(/["`\[\]]/g, '').split('.').at(-1)?.trim().toLowerCase() ?? value.toLowerCase();
13258
+ return normalize(sqlColumn) === normalize(declaredColumn);
13259
+ }
13260
+ /**
13261
+ * Gate a join probe against the declared relationship. Returns an error string
13262
+ * when the UNFILTERED key samples structurally contradict the declaration:
13263
+ * - zero key overlap while both sides have rows (wrong/mistyped key), or
13264
+ * - duplicate keys on the declared "one" side of a *_to_one / one_to_* edge.
13265
+ * Sampling caveat: gates only fire on definitive contradictions, never on low
13266
+ * match rates, so a sparse but real relationship still executes.
13267
+ */
13268
+ export function exploratoryProbeContradiction(rows, join, edge) {
13269
+ const row = Array.isArray(rows) && rows[0] && typeof rows[0] === 'object'
13270
+ ? rows[0]
13271
+ : {};
13272
+ const number = (key) => {
13273
+ const value = Number(row[key]);
13274
+ return Number.isFinite(value) ? value : 0;
13275
+ };
13276
+ const leftRows = number('left_sample_rows');
13277
+ const rightRows = number('right_sample_rows');
13278
+ const joined = number('joined_rows');
13279
+ if (leftRows > 0 && rightRows > 0 && joined === 0) {
13280
+ return `The join key ${join.leftColumn} = ${join.rightColumn} produced no matching rows between ${join.leftRelation} and ${join.rightRelation} (unfiltered sample). The declared key pair is likely wrong — review the relationship before executing this analysis.`;
13281
+ }
13282
+ if (!edge)
13283
+ return undefined;
13284
+ // Orient the declared cardinality onto the parsed join: which physical side is
13285
+ // the declared "one" side? Duplicated keys there contradict the declaration.
13286
+ const leftIsFrom = Boolean(join.leftRelation && declaredExploratoryRelationMatches(join.leftRelation, edge.fromRelation));
13287
+ const rightIsFrom = Boolean(join.rightRelation && declaredExploratoryRelationMatches(join.rightRelation, edge.fromRelation));
13288
+ if (leftIsFrom === rightIsFrom)
13289
+ return undefined; // orientation unresolved — do not guess
13290
+ const oneSide = edge.cardinality === 'many_to_one' ? 'to' : edge.cardinality === 'one_to_many' ? 'from' : edge.cardinality === 'one_to_one' ? 'both' : undefined;
13291
+ if (!oneSide)
13292
+ return undefined;
13293
+ // Per-side key duplication measured WITHIN each sample (max rows sharing one
13294
+ // key value). Duplicates inside a sample are definitive — a subset of a table
13295
+ // cannot show duplicates the full table does not have. The pairwise
13296
+ // max_matches_* columns are NOT used here: they inflate whenever the
13297
+ // legitimate "many" side repeats a key value.
13298
+ const leftDuplicated = number('max_left_rows_per_key') > 1;
13299
+ const rightDuplicated = number('max_right_rows_per_key') > 1;
13300
+ const fromSideIsLeft = leftIsFrom;
13301
+ const duplicatedSides = [
13302
+ ...(leftDuplicated ? [fromSideIsLeft ? 'from' : 'to'] : []),
13303
+ ...(rightDuplicated ? [fromSideIsLeft ? 'to' : 'from'] : []),
13304
+ ];
13305
+ const violated = oneSide === 'both' ? duplicatedSides.length > 0 : duplicatedSides.includes(oneSide);
13306
+ if (violated) {
13307
+ return `Relationship "${edge.relationshipId}" declares ${edge.cardinality}, but the sampled join keys show duplicates on the declared unique side. Executing would duplicate rows — validate and correct the relationship first.`;
13308
+ }
13309
+ return undefined;
13075
13310
  }
13076
13311
  function summarizeExploratoryJoinProbe(rows, leftRelation, leftColumn, rightRelation, rightColumn) {
13077
13312
  const row = Array.isArray(rows) && rows[0] && typeof rows[0] === 'object'
@@ -13671,6 +13906,28 @@ export function openBlockStudioDocument(projectRoot, relativePath, semanticLayer
13671
13906
  validation: validateBlockStudioSource(source, semanticLayer),
13672
13907
  };
13673
13908
  }
13909
+ function semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, driver) {
13910
+ if (provider === 'dbt' && metricFlowReady) {
13911
+ return { status: 'ready', engine: 'metricflow', reason: null };
13912
+ }
13913
+ const native = semanticLayer.composeQuery({ metrics: [metricName], dimensions: [], driver });
13914
+ if (native)
13915
+ return { status: 'ready', engine: 'native', reason: null };
13916
+ const metric = semanticLayer.getMetric(metricName);
13917
+ if (provider === 'dbt') {
13918
+ const kind = metric?.metricType || metric?.aggregation || metric?.type || 'metric';
13919
+ return {
13920
+ status: 'requires_setup',
13921
+ engine: null,
13922
+ reason: `${kind} metric requires MetricFlow execution. Install/configure the MetricFlow CLI, then refresh the semantic layer.`,
13923
+ };
13924
+ }
13925
+ return {
13926
+ status: 'unsupported',
13927
+ engine: null,
13928
+ reason: 'The metric does not have enough composable measure and relation metadata.',
13929
+ };
13930
+ }
13674
13931
  function parseBlockStudioArrayField(source, key) {
13675
13932
  const match = source.match(new RegExp(`\\b${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`, 'i'));
13676
13933
  if (!match)
@@ -13905,10 +14162,18 @@ function composeSemanticBlockSql(source, semanticLayer, options) {
13905
14162
  return { sql: null, diagnostics, semanticRefs };
13906
14163
  }
13907
14164
  if (!composed) {
14165
+ const provider = options?.projectConfig && isDbtSemanticRuntime(options.projectConfig, options.detectedProvider, semanticLayer) ? 'dbt' : (options?.detectedProvider ?? 'dql');
14166
+ const metricFlowReady = provider === 'dbt' && hasMetricFlowCli();
14167
+ const reasons = metrics.map((metricName) => {
14168
+ const capability = semanticMetricExecutionCapability(metricName, semanticLayer, provider, metricFlowReady, options?.driver);
14169
+ return capability.status === 'ready' ? null : `${metricName}: ${capability.reason}`;
14170
+ }).filter((reason) => Boolean(reason));
13908
14171
  diagnostics.push({
13909
14172
  severity: 'error',
13910
14173
  code: 'semantic_compose_failed',
13911
- message: `Could not compose SQL for semantic block metrics: [${metrics.join(', ')}].`,
14174
+ message: reasons.length > 0
14175
+ ? `Could not compose SQL for semantic block metrics. ${reasons.join(' ')}`
14176
+ : `Could not compose SQL for semantic block metrics: [${metrics.join(', ')}]. Check that the selected dimensions share a governed join path.`,
13912
14177
  });
13913
14178
  return { sql: null, diagnostics, semanticRefs };
13914
14179
  }